feat: Implement new 'soft, translucent, and alive' design system with updated UI components, navigation, and a new blog post.

This commit is contained in:
2025-12-10 03:08:08 -05:00
parent 347a61e1bf
commit 49243758c9
19 changed files with 507 additions and 319 deletions

View File

@@ -29,10 +29,10 @@
"fs": "0.0.1-security", "fs": "0.0.1-security",
"geist": "^1.4.2", "geist": "^1.4.2",
"lucide-react": "^0.454.0", "lucide-react": "^0.454.0",
"next": "^16.0.6", "next": "16.0.6",
"pdfjs-dist": "^4.10.38", "pdfjs-dist": "^4.10.38",
"radix-ui": "^1.4.2", "radix-ui": "^1.4.2",
"react": "^19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-pdf": "^9.2.1", "react-pdf": "^9.2.1",

View File

@@ -49,12 +49,12 @@ export default async function BlogPost({ params }: PageProps) {
return ( return (
<article className="animate-fade-in-up space-y-8"> <article className="animate-fade-in-up space-y-8">
<div className="mb-8"> <div className="mb-8">
<Button variant="ghost" asChild className="-ml-4 text-muted-foreground mb-4"> {/* <Button variant="ghost" asChild className="-ml-4 text-muted-foreground mb-4">
<Link href="/blog"> <Link href="/blog">
<ArrowLeft className="mr-2 h-4 w-4" /> <ArrowLeft className="mr-2 h-4 w-4" />
Back to Blog Back to Blog
</Link> </Link>
</Button> </Button> */}
<h1 className="text-3xl font-bold mb-4">{metadata.title}</h1> <h1 className="text-3xl font-bold mb-4">{metadata.title}</h1>

View File

@@ -7,7 +7,7 @@ import { Navigation } from "~/components/Navigation";
import { Sidebar } from "~/components/Sidebar"; import { Sidebar } from "~/components/Sidebar";
import { BreadcrumbWrapper } from "~/components/BreadcrumbWrapper"; import { BreadcrumbWrapper } from "~/components/BreadcrumbWrapper";
import { inter } from "~/lib/fonts"; import { inter, playfair } from "~/lib/fonts";
import { description, name } from "~/lib/data"; import { description, name } from "~/lib/data";
import "~/styles/globals.css"; import "~/styles/globals.css";
@@ -19,11 +19,21 @@ export const metadata: Metadata = {
export default function RootLayout({ children }: React.PropsWithChildren) { export default function RootLayout({ children }: React.PropsWithChildren) {
return ( return (
<html lang="en" className={inter.className} suppressHydrationWarning> <html
lang="en"
className={`${inter.variable} ${playfair.variable}`}
suppressHydrationWarning
>
<body <body
className="flex min-h-screen flex-col bg-background font-sans text-foreground" className="flex min-h-screen flex-col bg-background font-sans text-foreground relative"
suppressHydrationWarning suppressHydrationWarning
> >
{/* Background Elements */}
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px),linear-gradient(to_bottom,#80808012_1px,transparent_1px)] bg-[size:24px_24px] [mask-image:radial-gradient(ellipse_60%_50%_at_50%_50%,#000_70%,transparent_100%)]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div>
</div>
{env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && ( {env.NEXT_PUBLIC_UMAMI_WEBSITE_ID && (
<Script <Script
defer defer
@@ -36,20 +46,11 @@ export default function RootLayout({ children }: React.PropsWithChildren) {
)} )}
<Navigation /> <Navigation />
<div className="flex flex-1"> <div className="flex flex-1 pt-24 flex-col lg:flex-row">
{/* Desktop sidebar - extends to edge */} <Sidebar />
<aside className="hidden overflow-y-auto lg:sticky lg:top-16 lg:block lg:h-[calc(100vh-4rem)]"> <div className="flex-1 min-w-0 lg:pl-96">
<Sidebar /> <div className="mx-auto max-w-screen-xl px-6 sm:px-8 lg:pl-0 lg:pr-8">
</aside> <main className="pb-8 pt-4">
<div className="flex-1">
{/* Mobile sidebar - horizontal intro bar only on homepage */}
<div className="px-4 sm:px-6 lg:hidden lg:px-8">
<Sidebar />
</div>
<div className="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8">
<main className="py-8">
<BreadcrumbWrapper /> <BreadcrumbWrapper />
{children} {children}
</main> </main>

View File

@@ -68,7 +68,7 @@ export default function ProjectsPage() {
alt={project.imageAlt || project.title} alt={project.imageAlt || project.title}
width={400} width={400}
height={300} height={300}
className="h-auto w-full object-contain" className="h-auto w-full object-contain rounded-xl shadow-md"
priority={index === 0} priority={index === 0}
/> />
</div> </div>
@@ -114,7 +114,7 @@ export default function ProjectsPage() {
> >
<Link href={project.link}> <Link href={project.link}>
{project.title === {project.title ===
"LaTeX Introduction Tutorial" ? ( "LaTeX Introduction Tutorial" ? (
<> <>
<Play className="mr-2 h-4 w-4" /> <Play className="mr-2 h-4 w-4" />
Watch Tutorial Watch Tutorial
@@ -217,7 +217,7 @@ export default function ProjectsPage() {
alt={project.imageAlt || project.title} alt={project.imageAlt || project.title}
width={400} width={400}
height={250} height={250}
className="h-auto max-h-full w-full object-contain" className="h-auto max-h-full w-full object-contain rounded-xl shadow-md"
/> />
</div> </div>
)} )}

View File

@@ -54,7 +54,7 @@ export default function TripsPage() {
key={index} key={index}
className={`animate-fade-in-up-delay-${Math.min(index + 3, 4)} card-hover`} className={`animate-fade-in-up-delay-${Math.min(index + 3, 4)} card-hover`}
> >
<Card className="card-full-height overflow-hidden rounded-lg"> <Card className="card-full-height overflow-hidden">
<CardHeader className="p-0"> <CardHeader className="p-0">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex space-x-0 overflow-x-auto"> <div className="flex space-x-0 overflow-x-auto">

View File

@@ -0,0 +1,193 @@
"use client";
import { useState } from "react";
import { cn } from "~/lib/utils";
// Helper to convert HSL string (e.g., "0 0% 100%") to Hex
function hslToHex(hsl: string) {
const [h = 0, s = 0, l = 0] = hsl.split(" ").map((val) => parseFloat(val));
const hDecimal = h / 360;
const sDecimal = s / 100;
const lDecimal = l / 100;
let r, g, b;
if (s === 0) {
r = g = b = lDecimal; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = lDecimal < 0.5 ? lDecimal * (1 + sDecimal) : lDecimal + sDecimal - lDecimal * sDecimal;
const p = 2 * lDecimal - q;
r = hue2rgb(p, q, hDecimal + 1 / 3);
g = hue2rgb(p, q, hDecimal);
b = hue2rgb(p, q, hDecimal - 1 / 3);
}
const toHex = (x: number) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? "0" + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase();
}
const THEME_COLORS = {
light: {
background: "0 0% 100%",
foreground: "240 10% 3.9%",
card: "0 0% 100%",
"card-foreground": "240 10% 3.9%",
popover: "0 0% 100%",
"popover-foreground": "240 10% 3.9%",
primary: "240 5.9% 10%",
"primary-foreground": "0 0% 98%",
secondary: "240 4.8% 90%",
"secondary-foreground": "240 5.9% 10%",
muted: "240 4.8% 95.9%",
"muted-foreground": "240 3.8% 46.1%",
accent: "240 4.8% 95.9%",
"accent-foreground": "240 5.9% 10%",
destructive: "0 84.2% 60.2%",
"destructive-foreground": "0 0% 98%",
border: "240 5.9% 90%",
input: "240 5.9% 90%",
ring: "240 10% 3.9%",
},
dark: {
background: "240 10% 3.9%",
foreground: "0 0% 98%",
card: "240 10% 3.9%",
"card-foreground": "0 0% 98%",
popover: "240 10% 3.9%",
"popover-foreground": "0 0% 98%",
primary: "0 0% 98%",
"primary-foreground": "240 5.9% 10%",
secondary: "240 3.7% 20%",
"secondary-foreground": "0 0% 98%",
muted: "240 3.7% 15.9%",
"muted-foreground": "240 5% 64.9%",
accent: "240 3.7% 15.9%",
"accent-foreground": "0 0% 98%",
destructive: "0 62.8% 30.6%",
"destructive-foreground": "0 0% 98%",
border: "240 3.7% 15.9%",
input: "240 3.7% 15.9%",
ring: "240 4.9% 83.9%",
},
};
const ColorSwatch = ({ hsl, title }: { hsl: string; title: string }) => {
const [copied, setCopied] = useState(false);
const hex = hslToHex(hsl);
const copyToClipboard = () => {
navigator.clipboard.writeText(hex);
setCopied(true);
setTimeout(() => setCopied(false), 1000);
};
return (
<div className="group relative flex flex-col items-center">
<div
className={cn(
"h-12 w-12 flex-shrink-0 rounded-lg shadow-sm cursor-pointer transition-transform active:scale-95 border border-border/20",
)}
style={{ backgroundColor: hex }}
onClick={copyToClipboard}
title={`${title} (${hex})`}
/>
<div className="absolute -bottom-8 opacity-0 group-hover:opacity-100 transition-opacity bg-popover text-popover-foreground text-xs px-2 py-1 rounded shadow-md whitespace-nowrap z-10 pointer-events-none border border-border">
{copied ? "Copied!" : hex}
</div>
</div>
);
};
const ColorSection = ({ mode, colors }: { mode: "light" | "dark"; colors: typeof THEME_COLORS.light }) => {
return (
<div className="space-y-4">
<h4 className="font-heading font-bold capitalize text-lg">{mode} Mode</h4>
{/* Backgrounds */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="w-24 text-sm font-medium">Backgrounds</div>
<div className="flex gap-2 flex-wrap">
<ColorSwatch hsl={colors.background} title="background" />
<ColorSwatch hsl={colors.card} title="card" />
<ColorSwatch hsl={colors.popover} title="popover" />
<ColorSwatch hsl={colors.muted} title="muted" />
<ColorSwatch hsl={colors.secondary} title="secondary" />
</div>
</div>
{/* Foregrounds */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="w-24 text-sm font-medium">Foregrounds</div>
<div className="flex gap-2 flex-wrap">
<ColorSwatch hsl={colors.foreground} title="foreground" />
<ColorSwatch hsl={colors["card-foreground"]} title="card-foreground" />
<ColorSwatch hsl={colors["popover-foreground"]} title="popover-foreground" />
<ColorSwatch hsl={colors["muted-foreground"]} title="muted-foreground" />
<ColorSwatch hsl={colors["secondary-foreground"]} title="secondary-foreground" />
</div>
</div>
{/* Primary */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="w-24 text-sm font-medium">Primary</div>
<div className="flex gap-2 flex-wrap">
<ColorSwatch hsl={colors.primary} title="primary" />
<ColorSwatch hsl={colors["primary-foreground"]} title="primary-foreground" />
</div>
</div>
{/* Destructive */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="w-24 text-sm font-medium">Destructive</div>
<div className="flex gap-2 flex-wrap">
<ColorSwatch hsl={colors.destructive} title="destructive" />
<ColorSwatch hsl={colors["destructive-foreground"]} title="destructive-foreground" />
</div>
</div>
{/* UI Elements */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="w-24 text-sm font-medium">UI Elements</div>
<div className="flex gap-2 flex-wrap">
<ColorSwatch hsl={colors.border} title="border" />
<ColorSwatch hsl={colors.input} title="input" />
<ColorSwatch hsl={colors.ring} title="ring" />
</div>
</div>
</div>
);
};
export function ColorPalette() {
return (
<div className="space-y-12 not-prose my-8">
<div>
<h3 className="text-lg font-bold mb-4">Scales</h3>
<p className="text-muted-foreground mb-6">
The system uses semantic color scales that adapt to the current theme. Hover to see hex codes.
</p>
<div className="grid gap-12 lg:grid-cols-2">
<ColorSection mode="light" colors={THEME_COLORS.light} />
<div className="h-px bg-border/50 lg:hidden" />
<ColorSection mode="dark" colors={THEME_COLORS.dark} />
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { name } from "~/lib/data";
export function Footer() { export function Footer() {
return ( return (
<footer className="bg-background pb-4 text-foreground lg:hidden"> <footer className="bg-background py-2 text-foreground lg:hidden">
<div className="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8"> <div className="mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} {name[0]?.first}&nbsp; &copy; {new Date().getFullYear()} {name[0]?.first}&nbsp;

View File

@@ -34,13 +34,13 @@ export function Navigation() {
return ( return (
<> <>
<nav <nav
className={`sticky top-0 z-[51] border-b bg-card shadow-sm ${isOpen ? "border-transparent" : "border-border" className={`fixed top-4 left-4 right-4 z-[51] rounded-2xl border bg-background/80 backdrop-blur-md shadow-sm transition-all duration-200 ${isOpen ? "border-transparent" : "border-border/60"
}`} }`}
> >
<div className="relative mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8"> <div className="relative px-8">
<div className="flex h-16 items-center justify-between"> <div className="flex h-16 items-center justify-between">
<Link href="/" className="flex items-center py-2"> <Link href="/" className="flex items-center py-2">
<span className="text-xl font-semibold tracking-tight transition-colors hover:text-primary"> <span className="text-xl font-semibold font-heading tracking-tight transition-colors hover:text-primary">
Sean O&apos;Connor Sean O&apos;Connor
</span> </span>
</Link> </Link>
@@ -87,7 +87,7 @@ export function Navigation() {
aria-hidden="true" aria-hidden="true"
/> />
<div <div
className={`fixed left-0 right-0 top-16 z-50 overflow-hidden border-b border-border bg-card shadow-sm transition-all duration-300 lg:hidden ${isOpen ? "max-h-[calc(100vh-4rem)] opacity-100" : "max-h-0 opacity-0" className={`fixed left-4 right-4 top-24 z-50 overflow-hidden rounded-2xl border border-border/50 bg-background/80 backdrop-blur-md shadow-sm transition-all duration-300 lg:hidden ${isOpen ? "max-h-[calc(100vh-8rem)] opacity-100" : "max-h-0 opacity-0"
}`} }`}
> >
<div className="flex flex-col space-y-2 p-4"> <div className="flex flex-col space-y-2 p-4">

View File

@@ -12,6 +12,7 @@ import {
FileText, FileText,
Accessibility, Accessibility,
Briefcase, Briefcase,
File,
} from "lucide-react"; } from "lucide-react";
import { import {
Breadcrumb, Breadcrumb,
@@ -64,6 +65,10 @@ export function PageBreadcrumb() {
case "articles": case "articles":
icon = <Newspaper className="mr-1 h-3.5 w-3.5" />; icon = <Newspaper className="mr-1 h-3.5 w-3.5" />;
break; break;
case "blog":
icon = <Newspaper className="mr-1 h-3.5 w-3.5" />;
label = "Blog";
break;
case "publications": case "publications":
icon = <BookOpenText className="mr-1 h-3.5 w-3.5" />; icon = <BookOpenText className="mr-1 h-3.5 w-3.5" />;
label = "Publications"; label = "Publications";
@@ -88,7 +93,7 @@ export function PageBreadcrumb() {
label = "Accessibility"; label = "Accessibility";
break; break;
default: default:
icon = <ChevronRight className="mr-1 h-3.5 w-3.5" />; icon = <File className="mr-1 h-3.5 w-3.5" />;
} }
breadcrumbItems.push({ breadcrumbItems.push({

View File

@@ -4,23 +4,30 @@ import Image from "next/image";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { name, contact, location } from "~/lib/data"; import { name, contact, location } from "~/lib/data";
import { useState } from "react";
import { Skeleton } from "~/components/ui/skeleton";
export function Sidebar() { export function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const isHomePage = pathname === "/"; const isHomePage = pathname === "/";
const [isImageLoading, setIsImageLoading] = useState(true);
return ( return (
<> <>
{/* Mobile layout - horizontal intro bar only on home page */} {/* Mobile layout - horizontal intro bar only on home page */}
{isHomePage && ( {isHomePage && (
<div className="w-full space-y-4 pb-2 pt-6 lg:hidden"> <div className="w-full space-y-4 px-6 pb-2 pt-6 sm:px-8 lg:hidden">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="relative h-24 w-24 overflow-hidden"> <div className="relative h-24 w-24 overflow-hidden rounded-2xl border border-border/50 shadow-sm">
{isImageLoading && <Skeleton className="absolute inset-0 h-full w-full" />}
<Image <Image
src="/headshot.png" src="/headshot.png"
alt={`${name[0]?.first}&nbsp;${name[0]?.last}`} alt={`${name[0]?.first}&nbsp;${name[0]?.last}`}
width={240} width={240}
height={240} height={240}
className="object-cover" className={`object-cover duration-700 ease-in-out ${isImageLoading ? "scale-110 blur-2xl grayscale" : "scale-100 blur-0 grayscale-0"
}`}
onLoad={() => setIsImageLoading(false)}
priority priority
/> />
</div> </div>
@@ -70,18 +77,21 @@ export function Sidebar() {
)} )}
{/* Desktop layout - clean and elegant sidebar */} {/* Desktop layout - clean and elegant sidebar */}
<div className="hidden h-full w-80 border-r border-border bg-card lg:block"> <div className="hidden fixed top-24 left-4 bottom-4 w-80 rounded-3xl border border-border/60 bg-background/80 backdrop-blur-xl lg:block overflow-hidden">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Profile Section */} {/* Profile Section */}
<div className="flex-shrink-0 border-b border-border px-8 py-8"> <div className="flex-shrink-0 border-b border-border/50 px-8 py-8">
<div className="flex flex-col items-center space-y-4"> <div className="flex flex-col items-center space-y-4">
<div className="relative h-40 w-40 overflow-hidden border border-border"> <div className="relative h-40 w-40 overflow-hidden border border-border/50 rounded-3xl shadow-sm">
{isImageLoading && <Skeleton className="absolute inset-0 h-full w-full" />}
<Image <Image
src="/headshot.png" src="/headshot.png"
alt={`${name[0]?.first} ${name[0]?.last}`} alt={`${name[0]?.first} ${name[0]?.last}`}
width={400} width={400}
height={400} height={400}
className="h-full w-full object-cover" className={`h-full w-full object-cover duration-700 ease-in-out ${isImageLoading ? "scale-110 blur-2xl grayscale" : "scale-100 blur-0 grayscale-0"
}`}
onLoad={() => setIsImageLoading(false)}
priority priority
/> />
</div> </div>
@@ -127,7 +137,7 @@ export function Sidebar() {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex-shrink-0 border-t border-border px-8 py-4"> <div className="flex-shrink-0 border-t border-border/50 px-8 py-4">
<p className="text-center text-xs text-muted-foreground"> <p className="text-center text-xs text-muted-foreground">
&copy; {new Date().getFullYear()} {name[0]?.first} {name[0]?.last} &copy; {new Date().getFullYear()} {name[0]?.first} {name[0]?.last}
</p> </p>

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-xl text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{ {
variants: { variants: {
variant: { variant: {
@@ -22,9 +22,9 @@ const buttonVariants = cva(
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs", sm: "h-8 px-3 text-xs",
lg: "h-10 rounded-md px-8", lg: "h-10 px-8",
icon: "h-9 w-9", icon: "h-9 w-9 rounded-full",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -36,7 +36,7 @@ const buttonVariants = cva(
export interface ButtonProps export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>, extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { VariantProps<typeof buttonVariants> {
asChild?: boolean; asChild?: boolean;
} }

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-xl border bg-card text-card-foreground shadow", "rounded-3xl border border-border/60 bg-background/80 backdrop-blur-xl text-card-foreground shadow-sm overflow-hidden",
className, className,
)} )}
{...props} {...props}

View File

@@ -50,7 +50,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
"z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "z-50 min-w-[8rem] origin-[--radix-dropdown-menu-content-transform-origin] overflow-hidden rounded-xl border border-border/50 bg-background/80 backdrop-blur-md p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className, className,
)} )}
{...props} {...props}
@@ -68,7 +68,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md", "z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border border-border/50 bg-background/80 backdrop-blur-md p-1 text-popover-foreground shadow-md",
"origin-[--radix-dropdown-menu-content-transform-origin] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", "origin-[--radix-dropdown-menu-content-transform-origin] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className, className,
)} )}

View File

@@ -14,7 +14,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground", "inline-flex h-9 items-center justify-center rounded-xl bg-background/80 backdrop-blur-md border border-border/50 shadow-sm p-1 text-muted-foreground",
className, className,
)} )}
{...props} {...props}

View File

@@ -0,0 +1,123 @@
export const metadata = {
title: "Designing My System: Soft, Translucent, and Alive",
publishedAt: "2025-12-10",
summary: "A deep dive into the design philosophy behind my personal website's new theme, moving from rigid boxes to organic, living layers.",
tags: ["Design", "UI/UX", "TailwindCSS", "Frontend"],
image: "/images/design-system.png"
};
## The Philosophy: Soft, Translucent, and Alive
When I set out to redesign my personal website, I wanted to move away from the standard "developer portfolio" aesthetic—rigid grids, harsh borders, and flat colors. I wanted something that felt organic, something that breathed.
I call this simply **My Design System**. It's not a product; it's a reflection of my personal aesthetic. The core philosophy rests on three pillars:
1. **Soft**: Replacing sharp corners with deep `1rem` (16px) to `1.5rem` (24px) border radii.
2. **Translucent**: Using glassmorphism to create depth without heavy drop shadows.
3. **Alive**: Incorporating subtle, continuous motion that makes the site feel like a living organism rather than a static document.
## The "Living Blob"
The heartbeat of this theme is the background. Instead of a flat color or a static gradient, I implemented what I call the "Living Blob".
```tsx
// src/app/layout.tsx
<div className="fixed inset-0 -z-10 overflow-hidden pointer-events-none flex items-center justify-center">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808012_1px,transparent_1px)...] bg-[size:24px_24px]"></div>
<div className="w-[800px] h-[800px] bg-neutral-400/40 dark:bg-neutral-500/30 rounded-full blur-3xl animate-blob"></div>
</div>
```
This single element—a massive, blurred circle—animates on a 7-second infinite loop, gently pulsing and shifting. It sits behind a technical grid pattern, creating a juxtaposition between the organic and the engineered.
## Floating UI
One of the biggest structural changes was detaching the navigation and sidebar from the viewport edges.
In a traditional layout, the sidebar is stuck to the left, and the navbar is stuck to the top. In this system, they are **floating cards**.
- **Navbar**: Fixed `top-4`, `left-4`, `right-4`.
- **Sidebar**: Fixed `top-24`, `left-4`, `bottom-4`.
This creates a sense of layering. The UI elements aren't *part* of the window; they are tools floating *above* the content.
## Design Tokens
### 1. Shape & Rounding
I chose a base radius of `1rem` (16px) because it strikes the perfect balance between friendly and professional.
- **Cards**: `rounded-3xl` (24px). Large containers need softer corners to feel less imposing.
- **Buttons**: `rounded-xl` (12px). Tactile and clickable.
- **Icons**: `rounded-full`.
This softness extends to interaction states. When you hover over a card, the `overflow-hidden` property ensures that any inner content (like images or background fills) respects these curves perfectly.
### 2. Shadows & Glassmorphism
Instead of heavy drop shadows to show depth, I rely on **Glassmorphism**.
- **Surface**: `bg-background/80` with `backdrop-blur-md`. This allows the "Living Blob" to bleed through, tinting the UI with the background color.
- **Border**: `border-border/60`. A subtle, semi-transparent border defines the edges.
- **Shadow**: `shadow-sm`. A very light lift, just enough to separate the layer.
### 3. Color Palette
The system uses a strict monochrome HSL palette that adapts to the user's system preference.
import { ColorPalette } from "~/components/ColorPalette";
<ColorPalette />
## Typography: Editorial Meets Digital
For typography, I wanted to blend the readability of a digital product with the elegance of an editorial magazine. This "Editorial meets Digital" philosophy relies on the interplay between two distinct typefaces.
### The Serif: Playfair Display
<p className="text-4xl font-heading mb-6">Playfair Display</p>
I chose **Playfair Display** for all headings (`h1``h6`). It's a transitional serif typeface with high contrast strokes and delicate hairlines.
* **Why Serif?** Serifs feel "human", "established", and "emotional". They break the sterile "tech" vibe common in developer portfolios.
* **Why Playfair?** Its high contrast makes it perfect for large display sizes. It commands attention and adds a layer of sophistication that a sans-serif simply cannot achieve.
### The Sans: Inter
<p className="text-4xl font-sans mb-6">Inter</p>
I chose **Inter** for the body text. It is the gold standard for screen readability.
* **Why Sans?** Sans-serif fonts are "rational", "clean", and "invisible". They reduce cognitive load, making long-form reading (like this blog post) effortless.
* **Why Inter?** It was designed specifically for computer screens, with a tall x-height that remains legible even at small sizes.
### Implementation
The font stack is implemented using `next/font` for zero layout shift and CSS variables for Tailwind integration.
```ts
// tailwind.config.ts
theme: {
extend: {
fontFamily: {
sans: ["var(--font-sans)", ...fontFamily.sans],
heading: ["var(--font-heading)", ...fontFamily.serif],
},
},
}
```
```css
/* src/styles/globals.css */
h1, h2, h3, h4, h5, h6 {
@apply font-heading; /* Playfair Display */
}
body {
@apply font-sans; /* Inter */
}
```
## Conclusion
This design system is more than just a theme; it's a statement about how I view software. It shouldn't just be functional; it should be inviting. It should feel like a space you want to inhabit. By combining soft shapes, organic motion, and editorial typography, I hope I've created a digital garden that feels a little more human.

View File

@@ -1,16 +1,11 @@
export const metadata = { export const metadata = {
title: "Nand2Tetris Implementation (ECEG 431)", title: "ECEG 431 E-Portfolio",
publishedAt: "2025-12-01", publishedAt: "2025-12-01",
summary: "E-Portfolio and reflection for the Nand2Tetris course, covering the journey from NAND gates to a high-level compiler.", summary: "E-Portfolio and reflection for the Nand2Tetris course, covering the journey from NAND gates to a high-level compiler.",
tags: ["Simulation", "Compilers", "Python", "Assembly", "VM", "OS"], tags: ["Simulation", "Compilers", "Python", "Assembly", "VM", "OS"],
image: "/images/nand2tetris.png" image: "/images/nand2tetris.png"
}; };
# ECEG 431 E-Portfolio
Sean O'Connor '26 \
Fall 2025
## Assignment Prompt ## Assignment Prompt
An end-of-course reflection activity that is both An end-of-course reflection activity that is both
@@ -24,7 +19,7 @@ Before starting this, take some time to read back through your reflections on gr
The story begins ... where it begins. A downtrodden situation? A rough situation? But something prompts you to seek a change of status. The story begins ... where it begins. A downtrodden situation? A rough situation? But something prompts you to seek a change of status.
> Aladdin goes about his life as usual, but then saves someone---and learns they are royalty! He then decides to set out to make something of his self (and maybe win a princess's heart). > Aladdin goes about his life as usual, but then saves someone- and learns they are royalty! He then decides to set out to make something of his self (and maybe win a princess's heart).
What made you originally interested in this class? Or maybe, after hearing about things on the first week, what was it that really got you excited? What made you originally interested in this class? Or maybe, after hearing about things on the first week, what was it that really got you excited?

View File

@@ -1,6 +1,13 @@
import { Inter } from "next/font/google"; import { Inter, Playfair_Display } from "next/font/google";
export const inter = Inter({ export const inter = Inter({
subsets: ["latin"], subsets: ["latin"],
variable: "--font-sans",
display: "swap",
});
export const playfair = Playfair_Display({
subsets: ["latin"],
variable: "--font-heading",
display: "swap", display: "swap",
}); });

View File

@@ -4,199 +4,132 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 98%; --background: 0 0% 100%;
--foreground: 0 0% 14.5%; --foreground: 240 10% 3.9%;
--card: 0 0% 94%; --card: 0 0% 100%;
--card-foreground: 0 0% 14.5%; --card-foreground: 240 10% 3.9%;
--popover: 0 0% 94%; --popover: 0 0% 100%;
--popover-foreground: 0 0% 14.5%; --popover-foreground: 240 10% 3.9%;
--primary: 0 0% 55.5%; --primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98.5%; --primary-foreground: 0 0% 98%;
--secondary: 0 0% 92%; /* Darker secondary for better badge contrast against white bg */
--secondary-foreground: 0 0% 20.5%; --secondary: 240 4.8% 90%;
--muted: 0 0% 92%; --secondary-foreground: 240 5.9% 10%;
--muted-foreground: 0 0% 54.9%; --muted: 240 4.8% 95.9%;
--accent: 0 0% 92%; --muted-foreground: 240 3.8% 46.1%;
--accent-foreground: 0 0% 20.5%; --accent: 240 4.8% 95.9%;
--destructive: 7 85% 58%; --accent-foreground: 240 5.9% 10%;
--destructive-foreground: 0 0% 97%; --destructive: 0 84.2% 60.2%;
--border: 0 0% 88%; --destructive-foreground: 0 0% 98%;
--input: 0 0% 88%; --border: 240 5.9% 90%;
--ring: 0 0% 71%; --input: 240 5.9% 90%;
--chart-1: 0 0% 55.5%; --ring: 240 10% 3.9%;
--chart-2: 0 0% 55.5%; --chart-1: 12 76% 61%;
--chart-3: 0 0% 55.5%; --chart-2: 173 58% 39%;
--chart-4: 0 0% 55.5%; --chart-3: 197 37% 24%;
--chart-5: 0 0% 55.5%; --chart-5: 27 87% 67%;
--radius: 1rem;
--font-sans: Geist Mono, monospace; --sidebar: 0 0% 98%;
--font-serif: Geist Mono, monospace; --sidebar-foreground: 240 5.3% 26.1%;
--font-mono: Geist Mono, monospace; --sidebar-primary: 240 5.9% 10%;
--radius: 0rem; --sidebar-primary-foreground: 0 0% 98%;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); --sidebar-accent: 240 4.8% 95.9%;
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); --sidebar-accent-foreground: 240 5.9% 10%;
--shadow-sm: --sidebar-border: 220 13% 91%;
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0); --sidebar-ring: 217.2 91.2% 59.8%;
--shadow:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
--shadow-md:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px -1px hsl(0 0% 0% / 0);
--shadow-lg:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px -1px hsl(0 0% 0% / 0);
--shadow-xl:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px -1px hsl(0 0% 0% / 0);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0);
--tracking-normal: 0em;
--spacing: 0.25rem;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
--background: 0 0% 12%; --background: 240 10% 3.9%;
--foreground: 0 0% 98.5%; --foreground: 0 0% 98%;
--card: 0 0% 18%; --card: 240 10% 3.9%;
--card-foreground: 0 0% 98.5%; --card-foreground: 0 0% 98%;
--popover: 0 0% 26.9%; --popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98.5%; --popover-foreground: 0 0% 98%;
--primary: 0 0% 55.5%; --primary: 0 0% 98%;
--primary-foreground: 0 0% 98.5%; --primary-foreground: 240 5.9% 10%;
--secondary: 0 0% 26.9%; --secondary: 240 3.7% 20%;
--secondary-foreground: 0 0% 98.5%; /* Slightly lighter for visibility */
--muted: 0 0% 26.9%; --secondary-foreground: 0 0% 98%;
--muted-foreground: 0 0% 71%; --muted: 240 3.7% 15.9%;
--accent: 0 0% 37.2%; --muted-foreground: 240 5% 64.9%;
--accent-foreground: 0 0% 98.5%; --accent: 240 3.7% 15.9%;
--destructive: 7 85% 70%; --accent-foreground: 0 0% 98%;
--destructive-foreground: 0 0% 26.9%; --destructive: 0 62.8% 30.6%;
--border: 0 0% 25%; --destructive-foreground: 0 0% 98%;
--input: 0 0% 43.9%; --border: 240 3.7% 15.9%;
--ring: 0 0% 55.5%; --input: 240 3.7% 15.9%;
--chart-1: 0 0% 55.5%; --ring: 240 4.9% 83.9%;
--chart-2: 0 0% 55.5%; --chart-1: 220 70% 50%;
--chart-3: 0 0% 55.5%; --chart-2: 160 60% 45%;
--chart-4: 0 0% 55.5%; --chart-3: 30 80% 55%;
--chart-5: 0 0% 55.5%; --chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--font-sans: Geist Mono, monospace; --sidebar: 240 5.9% 10%;
--font-serif: Geist Mono, monospace; --sidebar-foreground: 240 4.8% 95.9%;
--font-mono: Geist Mono, monospace; --sidebar-primary: 224.3 76.3% 48%;
--radius: 0rem; --sidebar-primary-foreground: 0 0% 100%;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); --sidebar-accent: 240 3.7% 15.9%;
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0); --sidebar-accent-foreground: 240 4.8% 95.9%;
--shadow-sm: --sidebar-border: 240 3.7% 15.9%;
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0); --sidebar-ring: 217.2 91.2% 59.8%;
--shadow:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
--shadow-md:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px -1px hsl(0 0% 0% / 0);
--shadow-lg:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px -1px hsl(0 0% 0% / 0);
--shadow-xl:
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px -1px hsl(0 0% 0% / 0);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0);
} }
} }
body {
letter-spacing: var(--tracking-normal);
}
}
.border {
border: 1px solid hsl(var(--border));
}
.shadow {
box-shadow: var(--shadow);
}
/* Optional: Add smooth scrolling */
html {
scroll-behavior: smooth;
}
/* Optional: Improve text rendering */
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
} }
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground font-sans;
}
h1,
h2,
h3,
h4,
h5,
h6 {
@apply font-heading;
} }
} }
/* Remove all rounded corners for mono theme */ @layer utilities {
@layer components { .animate-blob {
/* Cards */ animation: blob 7s infinite;
.rounded-xl,
.rounded-lg,
.rounded-md,
.rounded-sm,
.rounded,
.rounded-full {
border-radius: 0 !important;
} }
/* Override specific component rounded classes */ .animation-delay-2000 {
[class*="rounded-"] { animation-delay: 2s;
border-radius: 0 !important; }
.animation-delay-4000 {
animation-delay: 4s;
} }
} }
/* Debug and ensure card backgrounds are visible */ @keyframes blob {
@layer components { 0% {
.bg-card { transform: translate(0px, 0px) scale(1);
background-color: hsl(var(--card)) !important;
} }
.bg-background { 33% {
background-color: hsl(var(--background)) !important; transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
} }
} }
/* Remove any image gradients/filters */
@layer components { @layer components {
img {
filter: none !important;
backdrop-filter: none !important;
}
}
/* Enhanced sidebar styling */
@layer components {
/* Smooth transitions for sidebar links */
.sidebar-link {
transition: all 0.15s ease-in-out;
border-radius: 0;
}
.sidebar-link:hover {
background-color: hsl(var(--accent));
color: hsl(var(--accent-foreground));
}
/* Profile image subtle border */
.profile-image {
border: 1px solid hsl(var(--border));
transition: border-color 0.2s ease;
}
.profile-image:hover {
border-color: hsl(var(--primary));
}
/* Truncate text in sidebar contact links */
.sidebar-contact-link span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 180px;
}
/* Animation utilities */ /* Animation utilities */
.animate-fade-in { .animate-fade-in {
@@ -223,36 +156,28 @@ body {
animation: fadeInUp 0.5s ease-in-out 0.4s backwards; animation: fadeInUp 0.5s ease-in-out 0.4s backwards;
} }
.hover-lift {
transition: transform 0.2s ease-in-out;
}
.hover-lift:hover {
transform: translateY(-2px);
}
/* Card hover effects */ /* Card hover effects */
.card-hover { .card-hover {
transition: all 0.2s ease-in-out; transition: all 0.3s ease-out;
} }
.card-hover:hover { .card-hover:hover {
transform: translateY(-2px); transform: translateY(-4px);
box-shadow: 0 4px 8px hsl(var(--foreground) / 0.1); box-shadow: 0 12px 24px -10px hsl(var(--foreground) / 0.1);
} }
/* Button hover effects to match card interactions */ /* Button hover effects */
.button-hover { .button-hover {
transition: all 0.2s ease-in-out; transition: all 0.2s ease-out;
} }
.button-hover:hover { .button-hover:hover {
transform: translateY(-1px); transform: translateY(-2px);
box-shadow: 0 2px 4px hsl(var(--foreground) / 0.1); box-shadow: 0 4px 12px -4px hsl(var(--foreground) / 0.1);
} }
/* Equal height cards in grid layouts */ /* Equal height cards in grid layouts */
.grid-equal-height > * { .grid-equal-height>* {
height: 100%; height: 100%;
} }
@@ -268,31 +193,8 @@ body {
flex-direction: column; flex-direction: column;
} }
/* Ensure buttons align to bottom of cards */ .card-content-stretch p:not(:last-child) {
.card-button-bottom { margin-bottom: 1rem;
margin-top: auto;
}
/* Card layout improvements for consistent button positioning */
.card-full-height {
display: flex;
flex-direction: column;
}
.card-full-height .card-header {
flex-shrink: 0;
}
.card-full-height .card-content {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card-full-height .card-footer {
flex-shrink: 0;
margin-top: auto;
} }
/* Text wrapping and overflow utilities */ /* Text wrapping and overflow utilities */
@@ -303,18 +205,6 @@ body {
hyphens: auto; hyphens: auto;
} }
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Ensure flex items don't shrink below content size */
.flex-shrink-0 {
flex-shrink: 0 !important;
}
/* Better line height for readability */
.leading-relaxed { .leading-relaxed {
line-height: 1.625; line-height: 1.625;
} }
@@ -322,51 +212,13 @@ body {
.leading-tight { .leading-tight {
line-height: 1.25; line-height: 1.25;
} }
/* Prevent layout shifts with min-width */
.min-w-0 {
min-width: 0;
}
/* Consistent spacing for list items */
.list-spacing li + li {
margin-top: 0.75rem;
}
/* Button positioning in card layouts */
.mt-auto {
margin-top: auto;
}
/* Grid layout improvements */
.grid-equal-height {
display: grid;
align-items: stretch;
}
/* Enhanced card content distribution */
.card-with-button {
height: 100%;
display: flex;
flex-direction: column;
}
.card-with-button .card-body {
flex: 1;
display: flex;
flex-direction: column;
}
.card-with-button .card-actions {
margin-top: auto;
padding-top: 1rem;
}
} }
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
} }
@@ -377,8 +229,9 @@ body {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(20px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }

View File

@@ -3,17 +3,18 @@ import { fontFamily } from "tailwindcss/defaultTheme";
export default { export default {
darkMode: "media", darkMode: "media",
content: ["./src/**/*.tsx"], content: ["./src/**/*.tsx", "./src/**/*.mdx"],
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {
sans: ["var(--font-sans)"], sans: ["var(--font-sans)", ...fontFamily.sans],
unineue: ["var(--font-unineue)"], heading: ["var(--font-heading)", ...fontFamily.sans],
}, },
borderRadius: { borderRadius: {
lg: "var(--radius)", lg: "var(--radius)",
md: "calc(var(--radius) - 2px)", md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)", sm: "calc(var(--radius) - 4px)",
xl: "calc(var(--radius) + 4px)",
}, },
colors: { colors: {
background: "hsl(var(--background))", background: "hsl(var(--background))",