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
2 changes: 1 addition & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ jobs:
with:
node-version: 24
cache: npm
- name: Guard immutable and append-only preservation fixtures
- name: Guard protected sources and published snapshots
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
FORCED_PUSH: ${{ github.event.forced }}
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@ I do not think the answer is a fixed biography. It is better understood through
the questions one pursues, the work one chooses to preserve, and the ways one’s
thinking changes over time.

I created this website to keep that record with continuity: present work, past
experience, and personal writing, each in its proper context. It is a living
archive rather than a conventional portfolio—selective, honest, and deliberately
unfinished.
I created this website to keep that record with continuity: education, past
experience, and the questions shaping my current chapter, each in its proper
context. It is a living archive rather than a conventional portfolio—selective,
honest, and deliberately unfinished.

[Visit the website →](https://www.theodoreoy.com/)
13 changes: 6 additions & 7 deletions app/README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,26 @@
# Application Routes

This directory uses the official Next.js App Router. Static pages keep their
bespoke copy beside their markup; repeatable collections are driven by typed,
validated registries.
bespoke copy beside their markup; the repeatable Past Experience collection is
driven by a typed, validated registry.

Key routes:

- `page.tsx` — Home.
- `page.tsx` — Home, including the progressively enhanced particle backdrop.
- `education/page.tsx` — Education.
- `past-experience/page.tsx` — Past Experience directory.
- `past-experience/[slug]/page.tsx` — all five experience domain pages.
- `now/page.tsx` — Current Chapter at `/now/`.
- `personal-posts/page.tsx` — Personal Posts index.
- `personal-posts/[slug]/page.tsx` — all article routes.

Shared responsibilities:

- `components/SiteShell.tsx` — navigation, profile sidebar, footer, and skip link.
- `globals.css` — approved visual system and responsive behavior.
- `components/particle-background/` — Home-only Three.js visual enhancement,
static fallback, reduced-motion behavior, and lifecycle cleanup.
- `globals.css` — visual system and responsive behavior.
- `layout.tsx` — global metadata, icons, and Person structured data.
- `lib/content/site.ts` — site constants, navigation, and metadata helper.
- `lib/content/experience.ts` — strict archive parser and experience registry.
- `lib/content/posts.ts` — article metadata registry.
- `lib/content/routes.ts` — canonical public routes used by the sitemap.
- `robots.ts`, `sitemap.ts`, and `not-found.tsx` — static discovery and failure pages.

Expand Down
9 changes: 7 additions & 2 deletions app/components/SiteShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,17 @@ import { navigation, type ActivePage } from "../lib/content/site";
export function SiteShell({
active,
children,
background,
variant = "default",
}: {
active?: ActivePage;
children: ReactNode;
background?: ReactNode;
variant?: "default" | "particle";
}) {
return (
<div className="site-shell">
<div className={`site-shell site-shell--${variant}`}>
{background}
<a className="skip-link" href="#main-content">
Skip to main content
</a>
Expand Down Expand Up @@ -60,7 +65,7 @@ export function SiteShell({
</div>
<p className="profile-name">Theodore Ouyang</p>
<p className="sidebar-bio">
<span>Strategy at a World Model Unicorn</span>
<span>Exploring practical AI use cases</span>
<span>Duke B.S. &amp; M.Eng.</span>
<span>Sequoia Scholar, Cohort 8</span>
</p>
Expand Down
67 changes: 67 additions & 0 deletions app/components/particle-background/ParticleBackground.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
.root {
position: fixed;
z-index: 0;
inset: 0;
overflow: hidden;
pointer-events: none;
}

.canvas {
display: block;
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 180ms ease-out;
}

.fallbackName {
position: absolute;
top: 50%;
left: 50%;
width: min(82vw, 980px);
margin: 0;
color: rgba(50, 45, 40, 0.7);
font-family: var(--display);
font-size: clamp(2rem, 8vw, 7rem);
font-weight: 700;
line-height: 0.92;
letter-spacing: -0.055em;
text-align: center;
text-transform: uppercase;
opacity: 1;
transform: translate(-50%, -50%);
transition: opacity 180ms ease-out;
}

.root[data-state="ready"] .canvas {
opacity: 1;
}

.root[data-state="ready"] .fallbackName {
opacity: 0;
}

.root[data-state="fallback"] .canvas {
opacity: 0;
}

.root[data-state="fallback"] .fallbackName {
opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
.root[data-state="ready"] .canvas {
opacity: 0.28;
transition-duration: 180ms;
}

.root[data-state="ready"] .fallbackName {
opacity: 1;
}
}

@media (prefers-contrast: more) {
.fallbackName {
color: var(--ink-strong);
}
}
53 changes: 53 additions & 0 deletions app/components/particle-background/ParticleBackground.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import { useEffect, useRef, useState } from "react";
import styles from "./ParticleBackground.module.css";

type ParticleState = "checking" | "ready" | "fallback";

export function ParticleBackground() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [state, setState] = useState<ParticleState>("checking");

useEffect(() => {
let cancelled = false;
let engine: import("./particle-engine").ParticleEngine | undefined;

const initialize = async () => {
const canvas = canvasRef.current;
if (!canvas) return;

try {
const { ParticleEngine } = await import("./particle-engine");
if (cancelled) return;
engine = new ParticleEngine(canvas, {
onUnavailable: () => setState("fallback"),
});
await engine.initialize();
if (cancelled) {
engine.dispose();
return;
}
setState("ready");
} catch {
engine?.dispose();
if (!cancelled) setState("fallback");
}
};

void initialize();
return () => {
cancelled = true;
engine?.dispose();
};
}, []);

return (
<div className={styles.root} data-state={state}>
<canvas className={styles.canvas} ref={canvasRef} aria-hidden="true" />
<span className={styles.fallbackName} aria-hidden="true">
Theodore Ouyang
</span>
</div>
);
}
49 changes: 49 additions & 0 deletions app/components/particle-background/particle-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { TimelinePhase } from "./particle-types";

export const PARTICLE_CONFIG = {
desktopParticles: 4_000,
mobileParticles: 2_200,
reducedMotionParticles: 900,
mobileBreakpoint: 720,
maxPixelRatio: 2,
camera: {
fov: 55,
near: 0.1,
far: 1_000,
z: 130,
parallaxX: 5,
parallaxY: 3,
},
points: {
size: 1.9,
scatterOpacity: 0.34,
wordOpacity: 0.86,
minimumGray: 0.3,
maximumGray: 0.52,
},
morph: {
maximumDelay: 0.38,
targetWidthRatio: 0.68,
targetHeightRatio: 0.22,
targetJitterRatio: 0.008,
},
pointer: {
radius: 48,
force: 80,
spring: 12,
damping: 9,
maximumOffset: 12,
},
} as const;

export const PARTICLE_TIMELINE: readonly TimelinePhase[] = [
{ kind: "hold", shape: "scatter", duration: 1.8 },
{ kind: "morph", to: "theodore", duration: 3 },
{ kind: "hold", shape: "theodore", duration: 4 },
{ kind: "morph", to: "scatter", duration: 3 },
{ kind: "hold", shape: "scatter", duration: 1.6 },
{ kind: "morph", to: "ouyang", duration: 3 },
{ kind: "hold", shape: "ouyang", duration: 4 },
{ kind: "morph", to: "scatter", duration: 3 },
{ kind: "hold", shape: "scatter", duration: 1.6 },
];
Loading
Loading