Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,14 +82,14 @@ Next.js 16 App Router site (React 19, TypeScript, Tailwind CSS). All routes wrap
Content is fully decoupled from layout. All editable content lives in `data/`:

- `data/site.ts` — `siteConfig` (name, copy, hero CTAs, about blurbs, contact info), `highlights`, `workingPrinciples`, `contactItems`, `socialLinks`
- `data/projects.ts` — `ProjectItem[]`; set `featured: true` for home page inclusion; `darkImage` swaps the thumbnail in night-race mode
- `data/projects.ts` — `ProjectItem[]`; set `featured: true` for home page inclusion. Each project points at a dark-theme cover in `public/images/projects/` (`lumen-cover.svg`, `branding-cover.svg`, `operations-cover.svg`)
- `data/navigation.ts` — `NavItem[]` driving the header nav
- `data/gallery.ts` — exports `gallery: GalleryPhoto[]`; `GalleryPhoto` is `{ image: StaticImageData | string; alt: string }`. The **first** entry is the full-width panorama lead banner; the rest flow into the masonry collage. Order is intentional (interleaves landscape/portrait frames and flows across tones) — keep that in mind when adding photos.

### Types (`lib/types.ts`)

- `ProjectLogo` — `{ label, status, image }`
- `ProjectItem` — `{ slug, title, category, summary, eyebrow?, subtitle?, relationshipLabel?, systemRole?: "interface" | "foundation", impact?, tags, href?, ctaLabel?, proofLogos?: ProjectLogo[], image, darkImage?, imageFit?: "cover" | "contain", featured? }`
- `ProjectItem` — `{ slug, title, category, summary, eyebrow?, subtitle?, relationshipLabel?, systemRole?: "interface" | "foundation", impact?, tags, href?, ctaLabel?, proofLogos?: ProjectLogo[], image, imageFit?: "cover" | "contain", featured? }`
- `ExperienceItem` — `{ title, organization, location, period, logoLabel, logoImage?, summary, bullets, tags }`
- `GalleryItem` — `{ title, location, description, image, orientation: "portrait" | "landscape" | "square" }` (defined in types; the gallery data uses the separate, simpler `GalleryPhoto` type from `data/gallery.ts`)
- `CinematicItem` — `{ title, location, description, video, poster }`
Expand Down Expand Up @@ -156,7 +156,7 @@ A **single warm light theme** defined on `:root` in `globals.css`. The earlier d
Tokens are consumed by Tailwind as `hsl(var(--token) / <alpha-value>)`.

`body` has a radial-gradient overlay (defined in `globals.css`).
`app/theme-fixes.css` scopes transitions to avoid layout jank — imported after `globals.css` in `layout.tsx`. It also holds per-page overrides (e.g. the Car Wash 3D-model label contrast fix). The Lumen and Operations case-study pages paint their own dark section backgrounds locally (`bg-[#0d0905]` etc.) and do not depend on a global dark theme. `ProjectCard` renders the single `image` (the `darkImage` field on `ProjectItem` is now unused).
`app/theme-fixes.css` scopes transitions to avoid layout jank — imported after `globals.css` in `layout.tsx`. It also holds per-page overrides (e.g. the Car Wash 3D-model label contrast fix). The Lumen and Operations case-study pages paint their own dark section backgrounds locally (`bg-[#0d0905]` etc.) and do not depend on a global dark theme. `ProjectCard` renders the single `image`; the project covers are dark-theme SVGs sharing one visual language (graphite base, amber glow, cream text, amber/teal accents) so they sit cleanly on the dark UI.

### Layout

Expand Down Expand Up @@ -187,7 +187,7 @@ Tokens are consumed by Tailwind as `hsl(var(--token) / <alpha-value>)`.
- 2-space indentation in all `.ts`/`.tsx` files.
- Use `<Image>` (Next.js) for all raster images. Use `<FallbackImage>` when the src might 404.
- **Photo uploads are usually HEIC** (often saved with a misleading `.JPG`/`.JPEG` extension). Browsers can't render HEIC, so convert before wiring anything in: `node scripts/convert-heic.js <input> <output.jpg>` (uses the `heic-convert` dependency). Verify real content with `file <path>` — the type declarations deliberately don't cover HEIC, so importing one fails at typecheck rather than shipping a broken image. Gallery images live in `public/images/gallery/`.
- SVG thumbnails for projects live in `public/images/projects/`. Light versions are the base name; dark versions append `-dark` (e.g., `lumen-thumbnail.svg` / `lumen-thumbnail-dark.svg`). The Fancy Car Wash project uses both `fancy-car-wash-logo.svg` (the project card image) and a separate `fancy-car-wash-thumbnail.svg`.
- SVG thumbnails for projects live in `public/images/projects/`. The three project card covers (`lumen-cover.svg`, `branding-cover.svg`, `operations-cover.svg`) are ASCII-only, dark-theme SVGs sharing one visual language — keep new covers in that style so they match the dark UI. Note: SVG text must use plain ASCII (no `·`/`—`), since non-ASCII punctuation can be written as invalid single-byte encodings that break SVG parsing.
- Org logos live in `public/images/logos/` (e.g., `michigan-wolverines.png`). Reference them via `logoImage` on `ExperienceItem`.
- Profile photos live in `public/images/profile/` (the live headshot is `headshot.jpg`).
- `application.fam` and `starter_app.c` are legacy Flipper files — do not modify.
Expand Down
18 changes: 9 additions & 9 deletions app/about/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ export const metadata: Metadata = pageMetadata({
const emailHref = `mailto:${siteConfig.email}?subject=Project%20%2F%20Role%20%2F%20Collaboration%20Inquiry`;

const focusAreas = [
{ icon: AppWindow, label: "Design & UI/UX", text: "Interfaces and details that decide how something feels, the work I do best." },
{ icon: BarChart3, label: "Strategy", text: "Clarifying the positioning and trade-offs decisions hinge on." },
{ icon: Database, label: "Operations", text: "Building the systems that keep things running." },
{ icon: Cpu, label: "Technology & AI", text: "Using software and AI where they genuinely move the work forward." }
{ icon: AppWindow, label: "Design & UI/UX", text: "The details that decide how something feels." },
{ icon: BarChart3, label: "Strategy", text: "The positioning and trade-offs decisions hinge on." },
{ icon: Database, label: "Operations", text: "The systems that keep things running." },
{ icon: Cpu, label: "Technology & AI", text: "Software and AI where they move the work forward." }
];

const bestFor = ["Design", "UI/UX", "Strategy", "Operations", "Technology"];
Expand All @@ -46,7 +46,7 @@ export default function AboutPage() {
How I work.
</h1>
<p className="mt-5 max-w-2xl text-base font-light leading-7 text-muted-foreground">
Design is what I do best, UI, UX, and the details that decide how something feels. Around it I'm broadly curious: strategy, the operations underneath, and the technology that ties it all together.
Design is what I do best, UI, UX, and the details that decide how something feels. Around it I'm curious about strategy, operations, and technology.
</p>
</Container>
</section>
Expand All @@ -70,13 +70,13 @@ export default function AboutPage() {
<Container>
<p className="text-[10px] font-medium uppercase tracking-[0.18em] text-accent/80">Contact</p>
<h2 className="mt-4 max-w-2xl font-display text-2xl tracking-[-0.035em] text-foreground sm:text-3xl">
For roles, projects, and useful introductions.
For roles, projects, and introductions.
</h2>
<div className="mt-6 flex items-center gap-3">
<a
href={emailHref}
aria-label="Email"
className="flex h-12 w-12 items-center justify-center rounded-full border border-foreground/[0.10] bg-card/60 text-accent shadow-soft transition hover:-translate-y-0.5 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
className="focus-ring flex h-12 w-12 items-center justify-center rounded-full border border-foreground/[0.10] bg-card/60 text-accent shadow-soft transition hover:-translate-y-0.5 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
>
<Mail className="h-4 w-4" strokeWidth={1.5} aria-hidden />
</a>
Expand All @@ -85,7 +85,7 @@ export default function AboutPage() {
target="_blank"
rel="noreferrer"
aria-label="LinkedIn"
className="flex h-12 w-12 items-center justify-center rounded-full border border-foreground/[0.10] bg-card/60 text-accent shadow-soft transition hover:-translate-y-0.5 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
className="focus-ring flex h-12 w-12 items-center justify-center rounded-full border border-foreground/[0.10] bg-card/60 text-accent shadow-soft transition hover:-translate-y-0.5 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
>
<LinkedInIcon className="h-4 w-4" />
</a>
Expand All @@ -94,7 +94,7 @@ export default function AboutPage() {
target="_blank"
rel="noreferrer"
aria-label="GitHub"
className="flex h-12 w-12 items-center justify-center rounded-full border border-foreground/[0.10] bg-card/60 text-accent shadow-soft transition hover:-translate-y-0.5 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
className="focus-ring flex h-12 w-12 items-center justify-center rounded-full border border-foreground/[0.10] bg-card/60 text-accent shadow-soft transition hover:-translate-y-0.5 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
>
<GithubIcon className="h-4 w-4" />
</a>
Expand Down
5 changes: 5 additions & 0 deletions app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -265,3 +265,8 @@ main {
.eyebrow {
@apply text-[11px] font-medium uppercase tracking-[0.22em] text-muted-foreground;
}

/* Consistent keyboard focus ring for interactive elements */
.focus-ring {
@apply focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background;
}
2 changes: 1 addition & 1 deletion app/portfolio/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export default function PortfolioPage() {
<PageIntro
eyebrow="Work"
title="Selected work."
description="Projects across product, brand, and operations: a product in development, a local brand, and the systems behind an organization."
description="Projects across product, brand, and operations."
/>

<section className="border-t border-foreground/[0.045] bg-background/35 py-10 sm:py-14">
Expand Down
26 changes: 26 additions & 0 deletions components/cards/project-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,29 @@ describe("ProjectCard — with href", () => {
expect(getByText("React")).toBeInTheDocument();
});
});

describe("ProjectCard — optional metadata", () => {
it("renders subtitle, relationship label, and cta label when provided", () => {
const props: ProjectItem = {
...baseProps,
href: "/portfolio/test-project",
subtitle: "A short supporting line.",
relationshipLabel: "In development",
ctaLabel: "View case study",
};
const { getByText } = render(<ProjectCard {...props} />);
expect(getByText("A short supporting line.")).toBeInTheDocument();
expect(getByText("In development")).toBeInTheDocument();
expect(getByText("View case study")).toBeInTheDocument();
});

it("shows at most four tags", () => {
const props: ProjectItem = {
...baseProps,
tags: ["One", "Two", "Three", "Four", "Five"],
};
const { queryByText } = render(<ProjectCard {...props} />);
expect(queryByText("Four")).toBeInTheDocument();
expect(queryByText("Five")).not.toBeInTheDocument();
});
});
2 changes: 1 addition & 1 deletion components/cards/project-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export function ProjectCard(props: ProjectItem) {
imageFit === "contain" ? "object-contain p-5 sm:p-8" : "object-cover"
);
const className = cn(
"block min-w-0 group overflow-hidden rounded-[1.35rem] border border-foreground/[0.055] bg-card/86 shadow-[inset_0_1px_0_hsl(var(--foreground)/0.018),0_10px_28px_hsl(30_30%_34%/0.045)] backdrop-blur-[1px] transition-all duration-500 ease-out hover:-translate-y-0.5 hover:border-foreground/[0.09] hover:bg-card hover:shadow-[inset_0_1px_0_hsl(var(--foreground)/0.025),0_16px_38px_hsl(28_32%_32%/0.07)]",
"block min-w-0 group overflow-hidden rounded-[1.35rem] border border-foreground/[0.055] bg-card/86 shadow-[inset_0_1px_0_hsl(var(--foreground)/0.018),0_10px_28px_hsl(30_30%_34%/0.045)] backdrop-blur-[1px] transition-all duration-500 ease-out hover:-translate-y-0.5 hover:border-foreground/[0.09] hover:bg-card hover:shadow-[inset_0_1px_0_hsl(var(--foreground)/0.025),0_16px_38px_hsl(28_32%_32%/0.07)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
isFoundation && "bg-card/72"
);
const body = <ProjectCardBody {...props} imageSrc={src} imageClassName={imageClassName} />;
Expand Down
10 changes: 5 additions & 5 deletions components/layout/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,29 @@ export function SiteFooter() {

<nav className="flex flex-wrap gap-x-5 gap-y-2" aria-label="Footer navigation">
{footerLinks.map((link) => (
<Link key={link.href} href={link.href} className="transition-colors hover:text-foreground">
<Link key={link.href} href={link.href} className="focus-ring rounded-sm transition-colors hover:text-foreground">
{link.label}
</Link>
))}
<Link href="/photography" className="transition-colors hover:text-foreground">
<Link href="/photography" className="focus-ring rounded-sm transition-colors hover:text-foreground">
Photography
</Link>
<Link href={emailHref} className="transition-colors hover:text-foreground">
<Link href={emailHref} className="focus-ring rounded-sm transition-colors hover:text-foreground">
Email
</Link>
<Link
href={siteConfig.linkedIn}
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-foreground"
className="focus-ring rounded-sm transition-colors hover:text-foreground"
>
LinkedIn
</Link>
<Link
href={siteConfig.github}
target="_blank"
rel="noreferrer"
className="transition-colors hover:text-foreground"
className="focus-ring rounded-sm transition-colors hover:text-foreground"
>
GitHub
</Link>
Expand Down
4 changes: 2 additions & 2 deletions components/layout/site-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function SiteHeader({ currentPath }: SiteHeaderProps) {
<Link
href="/"
aria-label={`${siteConfig.name} home`}
className="flex shrink-0 items-center gap-3 text-foreground"
className="focus-ring flex shrink-0 items-center gap-3 rounded-sm text-foreground"
>
<span className="inline-flex h-[24px] w-[24px] items-center justify-center rounded-full border border-foreground/12 bg-card/70">
<span className="font-mono text-[10px] font-medium text-foreground/72">M</span>
Expand All @@ -34,7 +34,7 @@ export function SiteHeader({ currentPath }: SiteHeaderProps) {
key={item.href}
href={item.href}
className={cn(
"shrink-0 border-b pb-1.5 text-[12px] font-normal tracking-[0.04em] transition-colors duration-200",
"focus-ring shrink-0 rounded-sm border-b pb-1.5 text-[12px] font-normal tracking-[0.04em] transition-colors duration-200",
isActive
? "border-foreground/32 text-foreground"
: "border-transparent text-foreground/48 hover:text-foreground"
Expand Down
6 changes: 3 additions & 3 deletions components/sections/home-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export function HomeHero() {
<Link
key={card.href}
href={card.href}
className="group rounded-[1.2rem] border border-foreground/10 bg-card/55 p-4 shadow-soft backdrop-blur-xl transition duration-300 hover:-translate-y-1 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
className="focus-ring group rounded-[1.2rem] border border-foreground/10 bg-card/55 p-4 shadow-soft backdrop-blur-xl transition duration-300 hover:-translate-y-1 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
>
<div className="flex items-center justify-between gap-4">
<span className="inline-flex items-center gap-2">
Expand Down Expand Up @@ -100,7 +100,7 @@ export function HomeHero() {
<Container>
<div className="mb-6 flex items-center justify-between">
<p className="text-[10px] font-medium uppercase tracking-[0.16em] text-accent/80">Selected work</p>
<Link href="/portfolio" className="text-xs font-light text-muted-foreground transition hover:text-foreground">
<Link href="/portfolio" className="focus-ring rounded-sm text-xs font-light text-muted-foreground transition hover:text-foreground">
View all
</Link>
</div>
Expand All @@ -110,7 +110,7 @@ export function HomeHero() {
<Link
key={project.slug}
href={project.href ?? "/portfolio"}
className="group rounded-[1.2rem] border border-foreground/10 bg-card/55 p-4 shadow-soft backdrop-blur-xl transition duration-300 hover:-translate-y-1 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
className="focus-ring group rounded-[1.2rem] border border-foreground/10 bg-card/55 p-4 shadow-soft backdrop-blur-xl transition duration-300 hover:-translate-y-1 hover:border-accent/30 hover:bg-card/80 hover:shadow-card"
>
<p className="text-[10px] font-medium uppercase tracking-[0.16em] text-accent/80">
{project.eyebrow}
Expand Down
2 changes: 1 addition & 1 deletion components/sections/home/home-about.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function HomeAboutSection() {
</p>
<Link
href="/about"
className="inline-flex items-center gap-2 border-b border-foreground/30 pb-1 text-sm font-light text-foreground transition-colors hover:text-muted-foreground"
className="focus-ring inline-flex items-center gap-2 rounded-sm border-b border-foreground/30 pb-1 text-sm font-light text-foreground transition-colors hover:text-muted-foreground"
>
Read full bio <ArrowUpRight className="h-3.5 w-3.5" />
</Link>
Expand Down
Loading
Loading