skeletal layout, theme functionality, mobile/desktop responsive, autoscroll

This commit is contained in:
2025-10-29 02:03:46 -05:00
commit 8f86e13dfc
24 changed files with 4950 additions and 0 deletions

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

69
src/App.tsx Normal file
View File

@@ -0,0 +1,69 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Navbar } from "./components/Navbar";
import { Section } from "./components/Section";
import { Hero } from "./components/Hero";
import { Placeholder } from "./components/Placeholder";
import { Footer } from "./components/Footer";
export default function App() {
const sections = useMemo(() => ["home", "projects", "experience"], []);
const refs = useRef<Record<string, HTMLElement | null>>({});
const [active, setActive] = useState<string>(sections[0]);
useEffect(() => {
const map: Record<string, HTMLElement | null> = {};
sections.forEach((id) => (map[id] = document.getElementById(id)));
refs.current = map;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (e.isIntersecting) setActive(e.target.id);
});
},
{ rootMargin: "-40% 0px -55% 0px", threshold: [0, 0.2, 1] }
);
Object.values(map).forEach((el) => el && io.observe(el));
return () => io.disconnect();
}, [sections]);
const handleNav = (id: string) => {
const el = refs.current[id];
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
return (
<div className="min-h-screen bg-bg text-text">
<Navbar onNav={handleNav} />
<main>
<Section id="home"><Hero /></Section>
<GradientBand />
<Section id="projects"><Placeholder title="Projects" /></Section>
<GradientBand />
<Section id="experience"><Placeholder title="Experience" /></Section>
</main>
<Footer />
{/* Active section indicator (optional) */}
<div className="fixed bottom-4 right-4 rounded-full border border-secondary bg-bg/80 px-3 py-1 text-sm text-text/80 shadow">{active.toUpperCase()}</div>
</div>
);
}
function GradientBand() {
return <div className="h-px bg-gradient-to-r from-secondary via-primary/60 to-secondary" />;
}

BIN
src/assets/Jody-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 KiB

BIN
src/assets/Jody.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

64
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,64 @@
import React from "react";
type Social = { label: string; href: string; icon?: React.ReactNode };
export function Footer({
year = new Date().getFullYear(),
socials = [
{ label: "GitHub", href: "#" },
{ label: "LinkedIn", href: "#" },
{ label: "Email", href: "#" },
],
showBackToTop = true,
}: {
year?: number;
socials?: Social[];
showBackToTop?: boolean;
}) {
return (
<footer className="border-t border-secondary bg-bg px-4 py-10">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-6 md:flex-row">
{/* Left: Brand + tagline */}
<div className="text-center md:text-left">
<div className="text-xl font-extrabold tracking-wide text-text">Jody Holt</div>
<p className="text-sm text-text/70">Design Develop Deliver</p>
</div>
{/* Middle: Links */}
<nav className="flex items-center gap-5">
<a className="text-text hover:text-primary" href="#projects">Projects</a>
<a className="text-text hover:text-primary" href="#experience">Experience</a>
<a className="text-text hover:text-primary" href="#home">Background</a>
</nav>
{/* Right: Socials */}
<div className="flex items-center gap-4 text-text">
{socials.map((s) => (
<a
key={s.label}
href={s.href}
aria-label={s.label}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-secondary hover:border-primary hover:text-primary"
title={s.label}
>
{/* replace with real SVGs later */}
{s.icon ?? <span className="h-2.5 w-2.5 rounded-full bg-current" />}
</a>
))}
</div>
</div>
<div className="mx-auto mt-6 flex max-w-7xl items-center justify-center gap-4">
<div className="text-center text-xs text-text/60">© {year} Jody Holt All rights reserved</div>
{showBackToTop && (
<button
onClick={() => window.scrollTo({ top: 0, behavior: "smooth" })}
className="rounded px-3 py-1 text-xs text-text/70 hover:text-primary border border-secondary hover:border-primary"
>
Back to top
</button>
)}
</div>
</footer>
);
}

158
src/components/Hero.tsx Normal file
View File

@@ -0,0 +1,158 @@
import React from "react";
import profileImage from "../assets/jody.png";
import jodyMobile from "../assets/Jody-mobile.png";
export function Hero() {
return (
<section className="relative w-full bg-hero">
<div className="md:hidden flex flex-col items-center text-center gap-2 min-h-[calc(100vh-64px)] py-6">
<h1
className="font-extrabold tracking-wide leading-tight text-text
text-2xl underline md:decoration-secondary decoration-primary"
>
Design. Develop. Deliver.
</h1>
<p className="text-sm text-text/80">
Driven by a genuine passion for creation through code.
</p>
<div className="relative h-68 w-68 rounded-full overflow-hidden mb-2">
<div className="absolute inset-0 rounded-full img-glow" />
<img
src={jodyMobile}
alt="Jody Holt"
className="relative z-[1] h-full w-full object-cover select-none pointer-events-none"
/>
</div>
<h2 className="mt- font-extrabold text-text leading-tight tracking-wide text-3xl">
Hello, Im Jody Holt
</h2>
<p className=" mt-5 text-lg text-base text-text/85">
Turning concepts into clean, functional code.
</p>
<p className="text-2xl font-semibold text-text mt-4">Its What I Do.</p>
<p className="mt-8 text-2xl text-text">I would love to connect!</p>
<div className="mt-2 mb-4 flex items-center justify-center gap-4">
{[
{ label: "GitHub", href: "#" },
{ label: "LinkedIn", href: "#" },
{ label: "Email", href: "#" },
].map((a) => (
<a
key={a.label}
href={a.href}
aria-label={a.label}
className="inline-flex h-12 w-12 items-center justify-center rounded-lg border border-secondary/70 bg-secondary/20 text-text hover:border-primary hover:text-primary transition"
>
<span className="h-3 w-3 rounded-full bg-current" />
</a>
))}
</div>
</div>
<div className="hidden md:block md:mx-auto max-w-7xl px-4">
<div
className="
min-h-[calc(100vh-64px)]
md:min-h-[calc(100vh-80px)]
flex flex-col md:flex-row items-start gap-10 lg:gap-10
py-8 md:py-1
"
>
<div className="shrink-0 self-start lg:pl-20">
<img
src={profileImage}
alt="Jody Holt"
className="w-[240px] sm:h-[280px] md:h-[700px] lg:h-[780px] xl:g-[800px] h-auto object-contain select-none pointer-events-none"
/>
</div>
<div className="flex-1 self-start md:pt-10 items-center text-center">
<h1
className="text-text font-extrabold tracking-wide leading-tight
text-3xl sm:text-4xl md:text-3xl lg:text-5xl xl:text-6xl underline md:decoration-secondary decoration-primary"
>
Design. Develop. Deliver.
</h1>
<p
className="mb-10 text-text/80
text-sm sm:text-base md:text-lg lg:text-xl xl:text-2"
>
Driven by a genuine passion for creation through code.
</p>
<h2
className="font-extrabold text-text leading-tight tracking-wide mb-5
text-2xl sm:text-3xl md:text-4xl lg:text-5xl xl:text-6xl"
>
Hello, Im Jody Holt
</h2>
<p
className="mb-3 text-text/85 md:mt-10 md:mb-5
text-base md:text-xl lg:text-2xl xl:text-3xl"
>
Turning concepts into clean, functional code.
</p>
<p
className="mb-30 text-text/85
text-base md:text-3xl lg:text-4xl xl:text-5xl
font-semibold"
>
Its What I Do.
</p>
<p
className="mb-8 text-text
text-lg md:text-4xl lg:text-5xl"
>
I would love to connect!
</p>
<div className="flex items-center justify-center gap-4 md:gap-6">
{[
{ label: "GitHub", href: "#" },
{ label: "LinkedIn", href: "#" },
{ label: "Email", href: "#" },
].map((a) => (
<a
key={a.label}
href={a.href}
className="inline-flex items-center justify-center rounded-xl border border-secondary/70 bg-secondary/20 text-text transition
h-10 w-10 sm:h-12 sm:w-12 md:h-14 md:w-14 lg:h-16 lg:w-16
hover:border-primary hover:text-primary"
aria-label={a.label}
title={a.label}
>
<span className="h-2.5 w-2.5 rounded-full bg-current" />
</a>
))}
</div>
</div>
</div>
</div>
</section>
);
}

90
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,90 @@
import React, { useState } from "react";
import { ThemeToggle } from "./ThemeToggle";
export function Navbar({ onNav }: { onNav: (id: string) => void }) {
const [open, setOpen] = useState(false);
const links = [
{ id: "home", label: "Background" },
{ id: "projects", label: "Projects" },
{ id: "experience", label: "Experience" },
];
const handleNav = (id: string) => {
onNav(id);
setOpen(false);
};
return (
<header className="sticky top-0 z-50 border-b border-secondary bg-bg/90 backdrop-blur h-16 md:h-20">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between px-4">
{/* Brand (stacked) */}
<div className="flex items-center gap-3">
<div className="leading-tight">
<div className="text-xl md:text-2xl font-extrabold tracking-wide text-text">
Jody Holt
</div>
<div className="text-[11px] md:text-sm text-text/70">
Passion Pioneer
</div>
</div>
</div>
{/* Desktop nav */}
<nav className="hidden items-center gap-6 md:flex">
{links.map((l) => (
<button
key={l.id}
className="text-text hover:text-primary"
onClick={() => handleNav(l.id)}
>
{l.label}
</button>
))}
<ThemeToggle />
</nav>
{/* Mobile controls */}
<div className="md:hidden">
<button
aria-expanded={open}
aria-label="Toggle menu"
className="rounded px-3 py-2 text-text hover:bg-secondary/60"
onClick={() => setOpen((v) => !v)}
>
<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor">
<path
d="M3 6h18M3 12h18M3 18h18"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</button>
</div>
</div>
{/* Mobile dropdown */}
{/* Mobile dropdown */}
<div
className={`md:hidden transition-[max-height] duration-300 ${
open ? "max-h-96 overflow-visible" : "max-h-0 overflow-hidden"
}`}
>
<div className="space-y-2 border-t border-secondary bg-bg px-4 py-3">
{links.map((l) => (
<button
key={l.id}
className="block w-full rounded px-3 py-2 text-left text-text hover:bg-secondary/60"
onClick={() => handleNav(l.id)}
>
{l.label}
</button>
))}
<div className="pt-2">
<ThemeToggle compact />
</div>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,18 @@
// =====================================
import React from "react";
export function Placeholder({ title }: { title: string }) {
return (
<div className="mx-auto max-w-7xl px-4 py-24">
<h3 className="mb-6 text-3xl font-bold text-text">{title}</h3>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-xl border border-secondary bg-secondary/30 p-6 text-text/85">
Card {i + 1}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import React from "react";
export function Section({ id, children }: React.PropsWithChildren<{ id: string }>) {
return (
<section id={id} className="scroll-mt-24">
{children}
</section>
);
}

View File

@@ -0,0 +1,48 @@
import React from "react";
import { useTheme } from "../hooks/useTheme";
export function ThemeToggle({ compact = false }: { compact?: boolean }) {
const { theme, setTheme } = useTheme();
const themes = ["a", "b", "c", "d", "e"] as const;
return (
<div className="relative inline-block text-text">
<details className="group">
<summary className="cursor-pointer select-none list-none inline-flex items-center gap-2 rounded px-3 py-1.5 bg-secondary/70 hover:bg-secondary focus:outline-none">
<span className="font-medium">
{compact ? "Theme" : "Toggle Theme"}
</span>
<span aria-hidden></span>
</summary>
<div
className="
absolute top-full mt-2
left-0 right-0 w-[calc(100vw-10rem)]
md:left-auto md:right- md:mx-0 md:w-44
rounded-lg border border-secondary bg-bg/95 p-2 shadow-xl backdrop-blur z-[70]
"
>
<ul className="space-y-1">
{themes.map((t) => (
<li key={t}>
<button
onClick={() => setTheme(t as any)}
className={`w-full rounded px-3 py-2 text-left hover:bg-secondary/60 ${
theme === t ? "outline outline-1 outline-primary" : ""
}`}
>
{/* preview dot uses theme accent variables you defined per theme (optional) */}
<span
className="mr-2 inline-block h-3 w-3 rounded-full align-middle"
style={{ background: `var(--color-accent-${t})` }}
/>
Theme {t.toUpperCase()}
</button>
</li>
))}
</ul>
</div>
</details>
</div>
);
}

21
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
export type ThemeKey = "a" | "b" | "c" | "d" | "e";
export function useTheme() {
const [theme, setTheme] = useState<ThemeKey>(() => {
const saved = (typeof window !== "undefined" && localStorage.getItem("theme")) as ThemeKey | null;
return saved ?? "a";
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
return { theme, setTheme };
}

232
src/index.css Normal file
View File

@@ -0,0 +1,232 @@
@import "tailwindcss";
/* Base design tokens that generate classes like bg-primary, text-text, etc. */
@theme {
--color-bg: #0e1116; /* defaults = Theme A */
--color-text: #e1e8ee;
--color-primary: #3d8eff;
--color-secondary: #1a1f26;
--color-tertiary: #00c9a7;
--color-contrast: #9ca3af;
--font-main: ui-sans-serif, system-ui, "Inter", "Segoe UI", sans-serif;
--font-title: "Nunito Sans", ui-sans-serif, system-ui, sans-serif;
--font-bold: "Bebas Neue", ui-sans-serif, system-ui, sans-serif;
}
/* AE themes: override the same tokens inside an attribute scope */
html[data-theme="a"] {
--color-bg: #0e1116;
--color-secondary: #1a1f26;
--color-text: #e1e8ee;
--color-primary: #3d8eff;
--color-tertiary: #00c9a7;
--color-contrast: #9ca3af;
}
html[data-theme="b"] {
--color-bg: #120e0e;
--color-secondary: #1c1818;
--color-text: #fefcfb;
--color-primary: #ff7043;
--color-tertiary: #ffd166;
--color-contrast: #a3948c;
}
html[data-theme="c"] {
--color-bg: #0d1318;
--color-secondary: #1b242c;
--color-text: #e8ecef;
--color-primary: #00a3c4;
--color-tertiary: #ff8a70;
--color-contrast: #9ca3af;
}
html[data-theme="d"] {
--color-bg: #0f1014;
--color-secondary: #1d1f24;
--color-text: #eaecef;
--color-primary: #6c78ff;
--color-tertiary: #a97bff;
--color-contrast: #9ca3af;
}
html[data-theme="e"] {
--color-bg: #0c1114;
--color-secondary: #182127;
--color-text: #edeff1;
--color-primary: #00d2a2;
--color-tertiary: #ffca57;
--color-contrast: #9ca3af;
}
/* theme-aware hero color follows --color-primary */
:root {
--hero-core: var(--color-primary);
}
@layer utilities {
/* Mobile / default */
.bg-hero {
background:
/* Top-right radial accent, similar to desktop */ radial-gradient(
120% 100% at 80% 10%,
color-mix(in oklab, var(--color-primary) 32%, transparent) 0%,
transparent 60%
),
/* Slight linear sweep from top to bottom */
linear-gradient(
180deg,
#0a0d13 0%,
var(--color-bg) 40%,
color-mix(in oklab, var(--color-primary) 10%, var(--color-bg) 90%)
100%
);
}
}
/* Desktop override */
@media (min-width: 768px) {
.bg-hero {
background:
/* small, softer highlight lower than the portrait rim */ radial-gradient(
95% 70% at 50% 28%,
color-mix(in oklab, var(--hero-core) 18%, transparent 82%) 0%,
transparent 56%
),
/* gentle bottom vignette for depth */
radial-gradient(
130% 90% at 50% 120%,
rgba(0, 0, 0, 0.32) 0%,
rgba(0, 0, 0, 0) 58%
),
/* base linear sweep */
linear-gradient(
185deg,
#0b0f15 0%,
var(--color-bg) 40%,
color-mix(in oklab, var(--hero-core) 12%, var(--color-bg) 88%) 100%
);
}
}
@media (min-width: 768px) {
/* Theme A deep blue */
html[data-theme="a"] .bg-hero {
background: radial-gradient(
135% 120% at 80% 48%,
color-mix(in oklab, var(--color-primary) 65%, black 35%) 0%,
color-mix(in oklab, var(--color-primary) 45%, black 55%) 38%,
transparent 74%
),
linear-gradient(
165deg,
#080b10 0%,
color-mix(in oklab, var(--color-bg) 70%, black 30%) 46%,
#0a1324 100%
);
}
html[data-theme="b"] .bg-hero {
background: radial-gradient(
140% 110% at 76% 46%,
color-mix(in oklab, var(--color-primary) 60%, black 40%) 0%,
color-mix(in oklab, var(--color-primary) 40%, black 60%) 36%,
transparent 70%
),
linear-gradient(
185deg,
#140c0b 0%,
var(--color-bg) 40%,
color-mix(in oklab, var(--color-tertiary) 6%, var(--color-bg) 94%) 100%
);
}
/* Theme C teal/cyan */
html[data-theme="c"] .bg-hero {
background: radial-gradient(
140% 120% at 76% 48%,
color-mix(in oklab, var(--color-primary) 58%, black 42%) 0%,
color-mix(in oklab, var(--color-primary) 40%, black 60%) 36%,
transparent 72%
),
linear-gradient(
165deg,
#081016 0%,
color-mix(in oklab, var(--color-bg) 62%, black 38%) 44%,
#0a1822 100%
);
}
/* Theme D indigo/violet */
html[data-theme="d"] .bg-hero {
background: radial-gradient(
135% 120% at 80% 48%,
color-mix(in oklab, var(--color-primary) 60%, black 40%) 0%,
color-mix(in oklab, var(--color-primary) 38%, black 62%) 36%,
transparent 72%
),
linear-gradient(
165deg,
#090a10 0%,
color-mix(in oklab, var(--color-bg) 68%, black 32%) 46%,
#111328 100%
);
}
/* Theme E emerald */
html[data-theme="e"] .bg-hero {
background: radial-gradient(
140% 120% at 78% 48%,
color-mix(in oklab, var(--color-primary) 58%, black 42%) 0%,
color-mix(in oklab, var(--color-primary) 38%, black 62%) 34%,
transparent 70%
),
linear-gradient(
165deg,
#07100e 0%,
color-mix(in oklab, var(--color-bg) 64%, black 36%) 44%,
#0a1c1a 100%
);
}
}
@media (min-width: 768px) {
.bg-hero::before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
/* left-to-right fade of darkness */
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0.34) 30%,
rgba(0, 0, 0, 0.18) 42%,
rgba(0, 0, 0, 0) 50%
);
z-index: 0;
}
/* keep content above the overlay */
.bg-hero > * {
position: relative;
z-index: 1;
}
}
@layer utilities {
.img-glow {
background:
radial-gradient(
68% 68% at 50% 42%,
color-mix(in oklab, var(--color-primary) 100%, black 0%) 0%,
color-mix(in oklab, var(--color-primary) 70%, black 30%) 40%,
color-mix(in oklab, var(--color-primary) 45%, black 55%) 70%,
color-mix(in oklab, var(--color-primary) 20%, black 80%) 85%
),
radial-gradient(
80% 80% at 50% 50%,
rgba(0,0,0,0) 58%,
rgba(0,0,0,0.35) 78%,
rgba(0,0,0,0.55) 100%
);
box-shadow: inset 0 0 24px rgba(0, 0, 0, 0.35);
}
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)