From 515f032f5d507ec647299ecdc61781f328818dfb Mon Sep 17 00:00:00 2001 From: Thomas Bohn Date: Wed, 8 Jul 2026 21:10:14 -0500 Subject: [PATCH] 10 UI/UX enhancements + cycling role typewriter (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new UI enhancement components - RoleTypewriter: cycles through prior role titles before settling on the current one, with a blinking cursor - ScrollReveal: fades/slides content in via IntersectionObserver as it scrolls into view, with a safety-net timeout so content is never permanently stuck invisible - BackToTop: floating button that appears after scrolling and smooth-scrolls to the top of the page - ReadingProgressBar / ShareButton: blog post reading aids Co-authored-by: Thomas Bohn * Wire skip-link, back-to-top, and OG share image into the root layout - Add a visually-hidden skip-to-content link for keyboard/screen-reader users - Give
an id so the skip-link has a target - Mount the global BackToTop button - Point openGraph/twitter metadata at the new social share image Co-authored-by: Thomas Bohn * Generate branded OG/Twitter share image Adds scripts/og-image/generate-og-image.js (sharp-composited from the profile photo + Tableau Color Blind brand palette) and commits the generated public/images/og-image.png. Wired into metadata in a prior commit. Documented regeneration in the README, same pattern as the existing icon generator. Co-authored-by: Thomas Bohn * Default to system color-scheme preference for first-time visitors Previously the theme always defaulted to light until a visitor made an explicit choice (persisted in localStorage). Now, when there's no saved preference, honor prefers-color-scheme: dark before falling back to light. Co-authored-by: Thomas Bohn * Add mobile hamburger nav and animated active-link indicator - Replace the always-expanded mobile nav block with a hamburger toggle that smoothly expands/collapses (CSS grid-rows trick, no JS height measuring), closing automatically on route change - Extract a shared DesktopNavLink with an animated underline that scales in on hover and stays put in the accent color for the active page Co-authored-by: Thomas Bohn * Add reading time, progress bar, share, and scroll-reveal to blog pages - lib/blog.ts: estimate reading time from rendered word count, exposed as BlogPost.readingTimeMinutes - Blog listing: show ' · X min read' per post; reveal each post card via ScrollReveal (staggered) instead of animating on mount; add the role typewriter to the page header - Blog post: mount a top-of-viewport ReadingProgressBar, add a ShareButton (Web Share API with clipboard fallback) next to 'Back to Blog', and show reading time alongside the date Co-authored-by: Thomas Bohn * Add role typewriter and scroll-reveal animations across all pages Replace the static 'PRODUCT MANAGER & SOFTWARE DESIGNER' role tag with the RoleTypewriter component (Home, About, Contact, Certifications, Projects, Hub, Links, V2ME), and swap each page's on-mount fade-in-up sections for ScrollReveal so below-the-fold content animates in as it's scrolled into view rather than all at once on load. Co-authored-by: Thomas Bohn --------- Co-authored-by: Cursor Agent --- README.md | 6 + app/about/page.tsx | 20 +- app/blog/[slug]/page.tsx | 22 +- app/blog/page.tsx | 29 ++- app/certifications/page.tsx | 10 +- app/contact/page.tsx | 16 +- app/layout.tsx | 21 +- app/page.tsx | 16 +- app/projects/page.tsx | 10 +- components/BackToTop.tsx | 48 +++++ components/Header.tsx | 266 ++++++++++++++++--------- components/RoleTypewriter.tsx | 89 +++++++++ components/ScrollReveal.tsx | 69 +++++++ components/ThemeProvider.tsx | 4 + components/blog/ReadingProgressBar.tsx | 45 +++++ components/blog/ShareButton.tsx | 55 +++++ components/hub/HubPageWrapper.tsx | 25 ++- components/links/LinksPageWrapper.tsx | 25 ++- components/v2me/V2MePageWrapper.tsx | 10 +- lib/blog.ts | 13 ++ package.json | 1 + public/images/og-image.png | Bin 0 -> 390246 bytes scripts/og-image/generate-og-image.js | 145 ++++++++++++++ 23 files changed, 787 insertions(+), 158 deletions(-) create mode 100644 components/BackToTop.tsx create mode 100644 components/RoleTypewriter.tsx create mode 100644 components/ScrollReveal.tsx create mode 100644 components/blog/ReadingProgressBar.tsx create mode 100644 components/blog/ShareButton.tsx create mode 100644 public/images/og-image.png create mode 100644 scripts/og-image/generate-og-image.js diff --git a/README.md b/README.md index b3b246b..1b7f411 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,12 @@ Site icons live in `public/icons/`: To regenerate PNGs after editing the SVG: `npm run generate:icons` (requires `sharp` as a devDependency). +## 🖼️ Social Share Image + +`public/images/og-image.png` is the Open Graph / Twitter Card image shown when a link to the site is shared. It's generated from `public/images/profile.jpg` plus the site's brand colors. + +To regenerate it after updating the profile photo or brand colors: `npm run generate:og-image` (requires `sharp` as a devDependency). + ## 📝 Content Management - **Blog Posts**: Written in Markdown and stored in `content/blog/` diff --git a/app/about/page.tsx b/app/about/page.tsx index decd1c9..c23d35e 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -1,5 +1,7 @@ import Image from 'next/image' import type { Metadata } from 'next' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'About', @@ -16,7 +18,9 @@ export default function About() {
{/* Header section with role tag */}
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

Thomas Bohn @@ -54,7 +58,8 @@ export default function About() {

{/* Right column - About content */} -
+
+

{'⟩'} @@ -108,7 +113,9 @@ export default function About() {

+ +

{'⟩'} @@ -145,7 +152,9 @@ export default function About() {

+ +

{'⟩'} @@ -182,7 +191,9 @@ export default function About() {

+ +

{'⟩'} @@ -235,7 +246,9 @@ export default function About() {

+
+

{'⟩'} @@ -270,7 +283,9 @@ export default function About() {

+
+

{'⟩'} @@ -291,6 +306,7 @@ export default function About() { Contact Me

+
diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index efe7041..25e5521 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -3,6 +3,8 @@ import { getBlogPost, getBlogPosts } from '@/lib/blog' import { notFound } from 'next/navigation' import Link from 'next/link' import { FaMedium } from 'react-icons/fa' +import { ReadingProgressBar } from '@/components/blog/ReadingProgressBar' +import { ShareButton } from '@/components/blog/ShareButton' export const dynamicParams = false @@ -30,10 +32,11 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str return (
+

{'>'} SOFTWARE ENGINEER

-
+

{'// BLOG'}

@@ -44,6 +47,7 @@ export default async function BlogPost({ params }: { params: Promise<{ slug: str > ← Back to Blog + {post.mediumUrl && (
-

- {new Date(post.date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} +

+ + {new Date(post.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + + {post.readingTimeMinutes} min read

{post.title.includes(':') ? ( diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 3b51d3f..134f40d 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -2,6 +2,8 @@ import Link from 'next/link' import type { Metadata } from 'next' import { getBlogPosts } from '@/lib/blog' import { FaMedium } from 'react-icons/fa' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'Blog', @@ -19,7 +21,9 @@ export default async function Blog() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} {'BLOG'} @@ -37,10 +41,10 @@ export default async function Blog() {

) : ( -
- {posts.map((post) => ( +
+ {posts.map((post, index) => ( +
-

- {new Date(post.date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'long', - day: 'numeric', - })} +

+ + {new Date(post.date).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + })} + + + {post.readingTimeMinutes} min read

{post.excerpt} @@ -93,6 +101,7 @@ export default async function Blog() {

+ ))}
)} diff --git a/app/certifications/page.tsx b/app/certifications/page.tsx index a90e367..0e54dbd 100644 --- a/app/certifications/page.tsx +++ b/app/certifications/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next' import { getLearningData } from '@/lib/hub' import { LearningSection } from '@/components/hub/LearningSection' import { LearningPageHeader } from '@/components/hub/LearningPageHeader' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'Certifications', @@ -19,7 +21,9 @@ export default async function Certifications() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} @@ -32,13 +36,13 @@ export default async function Certifications() {

-
+ -
+
diff --git a/app/contact/page.tsx b/app/contact/page.tsx index 4a81905..c65d9ee 100644 --- a/app/contact/page.tsx +++ b/app/contact/page.tsx @@ -2,6 +2,8 @@ import { useState } from 'react' import { FaLinkedin, FaEnvelope, FaCopy, FaCheck, FaLock } from 'react-icons/fa' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export default function Contact() { const [copied, setCopied] = useState(false) @@ -27,18 +29,21 @@ export default function Contact() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} {'CONTACT'}

- I'm always open to discussing new opportunities, technical challenges, or potential collaborations. Reach out through any of the channels below. + I'm always open to discussing new opportunities, technical challenges, or potential collaborations. Reach out through any of the channels below.

-
+
{/* Email Section */} +
@@ -72,8 +77,10 @@ export default function Contact() {
+ {/* LinkedIn Section */} +
@@ -97,8 +104,10 @@ export default function Contact() {
+ {/* Other Section */} +

More Ways to Connect

@@ -118,6 +127,7 @@ export default function Contact() {

+
diff --git a/app/layout.tsx b/app/layout.tsx index 7d75af5..d5b915b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { Header } from '@/components/Header' import { Footer } from '@/components/Footer' import { XangaShell } from '@/components/XangaShell' import { XangaLayoutWrapper } from '@/components/XangaLayoutWrapper' +import { BackToTop } from '@/components/BackToTop' const openSans = Open_Sans({ subsets: ['latin'], @@ -36,11 +37,20 @@ export const metadata: Metadata = { url: 'https://thomaslbohn.com', siteName: 'Thomas Bohn', type: 'website', + images: [ + { + url: '/images/og-image.png', + width: 1200, + height: 630, + alt: 'Thomas Bohn - Product Manager & Software Designer', + }, + ], }, twitter: { - card: 'summary', + card: 'summary_large_image', title: 'Thomas Bohn', description: 'Personal website and blog', + images: ['/images/og-image.png'], }, } @@ -84,14 +94,21 @@ export default function RootLayout({ + + Skip to content +
-
+
{children}
+
diff --git a/app/page.tsx b/app/page.tsx index da7d76a..91559fa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,7 @@ import Image from 'next/image' import mediumHomepage from '@/data/medium-homepage.json' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export default function Home() { const featuredMediumArticles = Array.isArray(mediumHomepage.featuredArticles) @@ -19,7 +21,9 @@ export default function Home() {
{/* Role tag with fade-in animation */} -

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{/* Main content area - two column layout */}
@@ -85,7 +89,7 @@ export default function Home() {
{/* Project Cards Section */} -
+

{'⟩'} {'MY WORK'} @@ -205,13 +209,13 @@ export default function Home() {

-
+ {/* Gradient Divider */} -
+
{/* Dig Deeper Section */} -
+

{'⟩'} {'DIG DEEPER'} @@ -242,7 +246,7 @@ export default function Home() { Request VIP Access

-
+
diff --git a/app/projects/page.tsx b/app/projects/page.tsx index 8de7e19..d5b8795 100644 --- a/app/projects/page.tsx +++ b/app/projects/page.tsx @@ -2,6 +2,8 @@ import type { Metadata } from 'next' import { getProjects } from '@/lib/projects' import { ProjectsSection } from '@/components/projects/ProjectsSection' import { ProjectsPageHeader } from '@/components/projects/ProjectsPageHeader' +import { RoleTypewriter } from '@/components/RoleTypewriter' +import { ScrollReveal } from '@/components/ScrollReveal' export const metadata: Metadata = { title: 'Projects', @@ -19,7 +21,9 @@ export default async function Projects() {
-

PRODUCT MANAGER & SOFTWARE DESIGNER

+

+ +

{'⟩'} @@ -32,9 +36,9 @@ export default async function Projects() {

-
+ -
+
diff --git a/components/BackToTop.tsx b/components/BackToTop.tsx new file mode 100644 index 0000000..814975f --- /dev/null +++ b/components/BackToTop.tsx @@ -0,0 +1,48 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' + +const SHOW_AFTER_PX = 400 + +export function BackToTop() { + const [visible, setVisible] = useState(false) + + useEffect(() => { + const handleScroll = () => setVisible(window.scrollY > SHOW_AFTER_PX) + handleScroll() + window.addEventListener('scroll', handleScroll, { passive: true }) + return () => window.removeEventListener('scroll', handleScroll) + }, []) + + const scrollToTop = useCallback(() => { + const prefersReducedMotion = + typeof window !== 'undefined' && + window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' }) + }, []) + + return ( + + ) +} diff --git a/components/Header.tsx b/components/Header.tsx index 2e74a93..1fc87e1 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -1,5 +1,6 @@ 'use client' +import { useEffect, useState } from 'react' import Link from 'next/link' import { usePathname } from 'next/navigation' import { useTheme } from './ThemeProvider' @@ -123,14 +124,76 @@ function LockIcon({ className }: { className?: string }) { ) } +// Desktop nav link with an animated underline indicator that grows in from +// the center on hover, and stays put (in the accent color) on the active page. +function DesktopNavLink({ + href, + label, + isActive, + vip, +}: { + href: string + label: string + isActive: boolean + vip?: boolean +}) { + return ( + + {vip && } + {label} + {vip && ( + + VIP + + )} +
+ {/* Hamburger toggle for mobile navigation */} +
- {/* Mobile menu */} -
-
- {/* Public navigation items */} -
- {publicNavItems.map((item) => { - const isActive = pathname === item.href || - (item.href !== '/' && pathname?.startsWith(item.href)) - return ( - - {item.label} - - ) - })} -
- - {/* Visual separator */} -
-