diff --git a/README.md b/README.md
index 0a5675d..1d217ac 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,47 @@
# Theodore Ouyang
-*Quis ego sum?* — Who am I?
+This repository contains the source for [theodoreoy.com](https://www.theodoreoy.com/),
+a selective personal archive built around four public sections:
-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.
+- **Home** — a particle-first opening stage followed by Theodore's profile,
+ biography, and contact coordinates.
+- **Education** — Theodore's academic record and selected coursework.
+- **Past Experience** — a five-domain archive generated from a validated source
+ record.
+- **Current Chapter** — a concise account of Theodore's exploration of practical
+ AI use cases in everyday life.
-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.
+The Home page gives visual priority to an interactive Three.js particle field.
+A same-shape particle fallback makes the first visible frame a stable word;
+the canvas then takes over without flashing solid text. The complete identity
+and biographical content remains available as static HTML below it.
+
+## Architecture and publishing
+
+The site uses the Next.js App Router, React, and TypeScript, and ships as a
+Next.js static export. There is no application server, database, or request-time
+API: GitHub Pages serves the generated HTML, CSS, JavaScript, fonts, and images.
+Pull requests validate the complete export; pushes to `main` deploy it.
+
+## Development and validation
+
+```sh
+npm ci --ignore-scripts
+npm run check
+npm audit --omit=dev
+npm run preview:static
+```
+
+`npm run check` runs linting, TypeScript, a production build, and final-artifact
+tests. Generated `.next/` and `out/` directories are intentionally not committed.
+See [`website-maintenance/`](website-maintenance/) for the source map, design
+boundaries, particle-system notes, and release checklist.
+
+## Rollback
+
+Publish changes through a pull request and keep `main` history intact. To undo a
+release, create a new `codex/` branch from the current `main`, revert the relevant
+merge commit, run the full validation suite, and merge that rollback through a
+new pull request. Do not rewrite the published branch.
[Visit the website →](https://www.theodoreoy.com/)
diff --git a/app/components/particle-background/ParticleBackground.module.css b/app/components/particle-background/ParticleBackground.module.css
index 558f846..107c3d9 100644
--- a/app/components/particle-background/ParticleBackground.module.css
+++ b/app/components/particle-background/ParticleBackground.module.css
@@ -15,26 +15,39 @@
width: 100%;
height: 100%;
opacity: 0;
- transition: opacity 180ms ease-out;
+ transition: opacity 120ms ease-out;
}
.fallbackName {
position: absolute;
top: 50%;
left: 50%;
- width: min(82vw, 980px);
+ width: min(70vw, 980px);
margin: 0;
- color: rgba(50, 45, 40, 0.7);
- font-family: var(--display);
- font-size: clamp(2rem, 8vw, 7rem);
+ color: rgba(55, 49, 44, 0.62);
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: clamp(3.2rem, 12.25vw, 16rem);
font-weight: 700;
line-height: 0.92;
- letter-spacing: -0.055em;
+ letter-spacing: 0;
text-align: center;
text-transform: uppercase;
opacity: 1;
transform: translate(-50%, -50%);
- transition: opacity 180ms ease-out;
+ transition: opacity 120ms ease-out;
+}
+
+@supports ((-webkit-background-clip: text) or (background-clip: text)) {
+ .fallbackName {
+ color: transparent;
+ background-image:
+ radial-gradient(circle, rgba(62, 55, 49, 0.62) 0 0.86px, transparent 1.04px),
+ radial-gradient(circle, rgba(143, 63, 40, 0.3) 0 0.54px, transparent 0.74px);
+ background-position: 0 0, 2px 2px;
+ background-size: 4px 4px, 7px 7px;
+ -webkit-background-clip: text;
+ background-clip: text;
+ }
}
.root[data-state="ready"] .canvas {
@@ -55,12 +68,13 @@
@media (prefers-reduced-motion: reduce) {
.root[data-state="ready"] .canvas {
- opacity: 0.28;
- transition-duration: 180ms;
+ opacity: 1;
+ transition: none;
}
.root[data-state="ready"] .fallbackName {
- opacity: 1;
+ opacity: 0;
+ transition: none;
}
}
@@ -68,4 +82,24 @@
.fallbackName {
color: var(--ink-strong);
}
+
+ @supports ((-webkit-background-clip: text) or (background-clip: text)) {
+ .fallbackName {
+ color: transparent;
+ background-image: radial-gradient(
+ circle,
+ rgba(32, 28, 24, 0.96) 0 1.05px,
+ transparent 1.24px
+ );
+ background-size: 4px 4px;
+ }
+ }
+}
+
+@media (forced-colors: active) {
+ .fallbackName {
+ color: CanvasText;
+ background: none;
+ -webkit-text-fill-color: currentColor;
+ }
}
diff --git a/app/components/particle-background/ParticleBackground.tsx b/app/components/particle-background/ParticleBackground.tsx
index 8d4f1df..5c5ca91 100644
--- a/app/components/particle-background/ParticleBackground.tsx
+++ b/app/components/particle-background/ParticleBackground.tsx
@@ -21,14 +21,16 @@ export function ParticleBackground() {
const { ParticleEngine } = await import("./particle-engine");
if (cancelled) return;
engine = new ParticleEngine(canvas, {
- onUnavailable: () => setState("fallback"),
+ onUnavailable: () => {
+ if (!cancelled) setState("fallback");
+ },
});
- await engine.initialize();
+ const ready = await engine.initialize();
if (cancelled) {
engine.dispose();
return;
}
- setState("ready");
+ setState(ready ? "ready" : "fallback");
} catch {
engine?.dispose();
if (!cancelled) setState("fallback");
@@ -46,7 +48,7 @@ export function ParticleBackground() {
- Theodore Ouyang
+ Theodore
);
diff --git a/app/components/particle-background/particle-config.ts b/app/components/particle-background/particle-config.ts
index 0cd4387..d7cbef3 100644
--- a/app/components/particle-background/particle-config.ts
+++ b/app/components/particle-background/particle-config.ts
@@ -1,9 +1,10 @@
import type { TimelinePhase } from "./particle-types";
export const PARTICLE_CONFIG = {
- desktopParticles: 4_000,
- mobileParticles: 2_200,
- reducedMotionParticles: 900,
+ initialShape: "theodore",
+ desktopParticles: 5_000,
+ mobileParticles: 2_800,
+ reducedMotionParticles: 1_600,
mobileBreakpoint: 720,
maxPixelRatio: 2,
camera: {
@@ -15,11 +16,13 @@ export const PARTICLE_CONFIG = {
parallaxY: 3,
},
points: {
- size: 1.9,
- scatterOpacity: 0.34,
- wordOpacity: 0.86,
- minimumGray: 0.3,
- maximumGray: 0.52,
+ size: 2.25,
+ scatterOpacity: 0.38,
+ wordOpacity: 0.96,
+ minimumGray: 0.18,
+ maximumGray: 0.38,
+ redLift: 0.018,
+ blueDrop: 0.012,
},
morph: {
maximumDelay: 0.38,
@@ -37,13 +40,12 @@ export const PARTICLE_CONFIG = {
} 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 },
+ { kind: "hold", shape: "theodore", duration: 5.5 },
+ { kind: "morph", to: "scatter", duration: 2.8 },
+ { kind: "hold", shape: "scatter", duration: 1.2 },
+ { kind: "morph", to: "ouyang", duration: 2.8 },
+ { kind: "hold", shape: "ouyang", duration: 4.5 },
+ { kind: "morph", to: "scatter", duration: 2.8 },
+ { kind: "hold", shape: "scatter", duration: 1.2 },
+ { kind: "morph", to: "theodore", duration: 2.8 },
];
diff --git a/app/components/particle-background/particle-engine.ts b/app/components/particle-background/particle-engine.ts
index c26c1fd..94537f4 100644
--- a/app/components/particle-background/particle-engine.ts
+++ b/app/components/particle-background/particle-engine.ts
@@ -48,6 +48,8 @@ export class ParticleEngine {
private points?: THREE.Points;
private positionAttribute?: THREE.BufferAttribute;
private samples?: SampledWords;
+ private resizeObserver?: ResizeObserver;
+ private viewportObserver?: IntersectionObserver;
private readonly position: Float32Array;
private readonly base: Float32Array;
@@ -81,6 +83,9 @@ export class ParticleEngine {
private cameraTargetX = 0;
private cameraTargetY = 0;
private unavailable = false;
+ private viewportVisible = true;
+ private viewportWidth = 0;
+ private viewportHeight = 0;
constructor(canvas: HTMLCanvasElement, { onUnavailable }: EngineOptions) {
this.canvas = canvas;
@@ -130,17 +135,12 @@ export class ParticleEngine {
PARTICLE_CONFIG.camera.far,
);
this.camera.position.z = PARTICLE_CONFIG.camera.z;
- this.samples = await sampleParticleWords();
- if (this.disposed) return;
+ this.samples = sampleParticleWords();
+ if (this.disposed) return false;
this.resize(false);
this.buildScatter();
- this.buildWordTarget(this.samples.theodore, this.theodore);
- this.buildWordTarget(this.samples.ouyang, this.ouyang);
- this.base.set(this.scatter);
- this.position.set(this.scatter);
- this.source.set(this.scatter);
- this.target.set(this.scatter);
+ this.resetToInitialShape();
this.buildColors();
this.geometry = new THREE.BufferGeometry();
@@ -161,7 +161,7 @@ export class ParticleEngine {
alphaTest: 0.08,
color: 0xffffff,
depthWrite: false,
- opacity: PARTICLE_CONFIG.points.scatterOpacity,
+ opacity: PARTICLE_CONFIG.points.wordOpacity,
size: PARTICLE_CONFIG.points.size,
sizeAttenuation: false,
transparent: true,
@@ -173,19 +173,29 @@ export class ParticleEngine {
this.scene.add(this.points);
this.canvas.addEventListener("webglcontextlost", this.handleContextLost);
+ this.renderCurrentFrame();
+ if (this.unavailable) return false;
+
window.addEventListener("resize", this.handleResize, { passive: true });
document.addEventListener("visibilitychange", this.handleVisibilityChange);
this.motionQuery.addEventListener("change", this.handleMotionPreferenceChange);
+ if ("ResizeObserver" in window) {
+ this.resizeObserver = new ResizeObserver(this.handleElementResize);
+ this.resizeObserver.observe(this.canvas);
+ }
+ if ("IntersectionObserver" in window) {
+ this.viewportObserver = new IntersectionObserver(this.handleViewportIntersection, {
+ rootMargin: "120px 0px",
+ });
+ this.viewportObserver.observe(this.canvas);
+ }
if (this.finePointer) {
window.addEventListener("pointermove", this.handlePointerMove, { passive: true });
window.addEventListener("pointerout", this.handlePointerOut, { passive: true });
}
- if (this.reducedMotion) {
- this.renderer.render(this.scene, this.camera);
- return;
- }
- this.start();
+ this.syncAnimationState();
+ return true;
}
private buildColors() {
@@ -193,9 +203,9 @@ export class ParticleEngine {
for (let index = 0; index < this.particleCount; index += 1) {
const gray = PARTICLE_CONFIG.points.minimumGray + this.random() * range;
const offset = index * 3;
- this.colors[offset] = gray;
+ this.colors[offset] = Math.min(1, gray + PARTICLE_CONFIG.points.redLift);
this.colors[offset + 1] = gray;
- this.colors[offset + 2] = Math.min(1, gray + 0.015);
+ this.colors[offset + 2] = Math.max(0, gray - PARTICLE_CONFIG.points.blueDrop);
}
}
@@ -386,7 +396,16 @@ export class ParticleEngine {
}
private start() {
- if (this.running || this.disposed || this.unavailable || this.reducedMotion) return;
+ if (
+ this.running ||
+ this.disposed ||
+ this.unavailable ||
+ this.reducedMotion ||
+ document.hidden ||
+ !this.viewportVisible
+ ) {
+ return;
+ }
this.running = true;
this.previousTime = performance.now();
this.animationFrame = requestAnimationFrame(this.animate);
@@ -397,6 +416,16 @@ export class ParticleEngine {
cancelAnimationFrame(this.animationFrame);
}
+ private renderCurrentFrame() {
+ if (!this.renderer || !this.scene || !this.camera) return;
+ this.renderer.render(this.scene, this.camera);
+ }
+
+ private syncAnimationState() {
+ if (this.reducedMotion || document.hidden || !this.viewportVisible) this.stop();
+ else this.start();
+ }
+
private animate = (time: number) => {
if (!this.running || !this.renderer || !this.scene || !this.camera) return;
const delta = Math.min((time - this.previousTime) / 1_000, 0.05);
@@ -411,9 +440,13 @@ export class ParticleEngine {
};
private resize(scaleExisting = true) {
- if (!this.renderer || !this.camera) return;
- const width = Math.max(1, window.innerWidth);
- const height = Math.max(1, window.innerHeight);
+ if (this.disposed || this.unavailable || !this.renderer || !this.camera) return;
+ const bounds = this.canvas.getBoundingClientRect();
+ const width = Math.max(1, bounds.width);
+ const height = Math.max(1, bounds.height);
+ const geometryChanged =
+ Math.abs(width - this.viewportWidth) > 0.5 ||
+ Math.abs(height - this.viewportHeight) > 0.5;
const previousWidth = this.visibleWidth;
const previousHeight = this.visibleHeight;
@@ -428,36 +461,37 @@ export class ParticleEngine {
2 * Math.tan(THREE.MathUtils.degToRad(PARTICLE_CONFIG.camera.fov / 2)) * PARTICLE_CONFIG.camera.z;
const visibleWidth = visibleHeight * this.camera.aspect;
- if (scaleExisting && previousWidth > 1 && previousHeight > 1) {
+ if (scaleExisting && geometryChanged && previousWidth > 1 && previousHeight > 1) {
const scaleX = visibleWidth / previousWidth;
const scaleY = visibleHeight / previousHeight;
- for (const buffer of [this.position, this.base, this.source, this.target, this.scatter]) {
- for (let index = 0; index < this.particleCount; index += 1) {
- const offset = index * 3;
- buffer[offset] *= scaleX;
- buffer[offset + 1] *= scaleY;
- }
+ for (let index = 0; index < this.particleCount; index += 1) {
+ const offset = index * 3;
+ this.scatter[offset] *= scaleX;
+ this.scatter[offset + 1] *= scaleY;
}
}
+ this.viewportWidth = width;
+ this.viewportHeight = height;
this.visibleWidth = visibleWidth;
this.visibleHeight = visibleHeight;
if (this.samples) {
this.buildWordTarget(this.samples.theodore, this.theodore);
this.buildWordTarget(this.samples.ouyang, this.ouyang);
}
+ if (scaleExisting && geometryChanged) this.resetToInitialShape();
if (this.positionAttribute) this.positionAttribute.needsUpdate = true;
- if (this.reducedMotion && this.scene) {
- this.renderer.render(this.scene, this.camera);
- }
+ if (this.reducedMotion && this.scene) this.renderCurrentFrame();
}
private handlePointerMove = (event: PointerEvent) => {
if (!this.camera || this.reducedMotion) return;
+ const bounds = this.canvas.getBoundingClientRect();
+ if (bounds.width <= 0 || bounds.height <= 0) return;
this.pointerPresent = true;
this.pointerNdc.set(
- (event.clientX / window.innerWidth) * 2 - 1,
- -(event.clientY / window.innerHeight) * 2 + 1,
+ ((event.clientX - bounds.left) / bounds.width) * 2 - 1,
+ -((event.clientY - bounds.top) / bounds.height) * 2 + 1,
);
this.updatePointerProjection();
this.cameraTargetX = this.pointerNdc.x * PARTICLE_CONFIG.camera.parallaxX;
@@ -473,22 +507,34 @@ export class ParticleEngine {
};
private handleResize = () => {
+ if (this.disposed || this.unavailable) return;
cancelAnimationFrame(this.resizeFrame);
this.resizeFrame = requestAnimationFrame(() => this.resize());
};
+ private handleElementResize: ResizeObserverCallback = () => {
+ this.handleResize();
+ };
+
private handleVisibilityChange = () => {
- if (document.hidden) this.stop();
- else this.start();
+ this.syncAnimationState();
};
- private resetToScatter() {
+ private handleViewportIntersection: IntersectionObserverCallback = (entries) => {
+ const entry = entries[0];
+ if (!entry) return;
+ this.viewportVisible = entry.isIntersecting;
+ this.syncAnimationState();
+ };
+
+ private resetToInitialShape() {
this.phaseIndex = 0;
this.phaseElapsed = 0;
- this.base.set(this.scatter);
- this.position.set(this.scatter);
- this.source.set(this.scatter);
- this.target.set(this.scatter);
+ const initialShape = this.shape(PARTICLE_CONFIG.initialShape);
+ this.base.set(initialShape);
+ this.position.set(initialShape);
+ this.source.set(initialShape);
+ this.target.set(initialShape);
this.repX.fill(0);
this.repY.fill(0);
this.repVX.fill(0);
@@ -501,7 +547,7 @@ export class ParticleEngine {
: this.particleCount,
);
this.points?.scale.setScalar(1);
- if (this.material) this.material.opacity = PARTICLE_CONFIG.points.scatterOpacity;
+ if (this.material) this.material.opacity = PARTICLE_CONFIG.points.wordOpacity;
if (this.camera) {
this.camera.position.x = 0;
this.camera.position.y = 0;
@@ -515,36 +561,42 @@ export class ParticleEngine {
this.pointerActive = false;
this.cameraTargetX = 0;
this.cameraTargetY = 0;
- this.resetToScatter();
+ this.resetToInitialShape();
if (this.reducedMotion) {
this.stop();
- if (this.renderer && this.scene && this.camera) {
- this.renderer.render(this.scene, this.camera);
- }
- } else if (!document.hidden) {
- this.start();
- }
+ this.renderCurrentFrame();
+ } else this.syncAnimationState();
};
- private handleContextLost = (event: Event) => {
- event.preventDefault();
+ private handleContextLost = () => {
+ if (this.unavailable || this.disposed) return;
this.unavailable = true;
this.stop();
this.canvas.hidden = true;
+ this.detachRuntimeListeners();
this.onUnavailable();
};
- dispose() {
- if (this.disposed) return;
- this.disposed = true;
- this.stop();
+ private detachRuntimeListeners() {
cancelAnimationFrame(this.resizeFrame);
+ this.resizeFrame = 0;
this.canvas.removeEventListener("webglcontextlost", this.handleContextLost);
window.removeEventListener("resize", this.handleResize);
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
this.motionQuery.removeEventListener("change", this.handleMotionPreferenceChange);
+ this.resizeObserver?.disconnect();
+ this.resizeObserver = undefined;
+ this.viewportObserver?.disconnect();
+ this.viewportObserver = undefined;
window.removeEventListener("pointermove", this.handlePointerMove);
window.removeEventListener("pointerout", this.handlePointerOut);
+ }
+
+ dispose() {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.stop();
+ this.detachRuntimeListeners();
this.geometry?.dispose();
this.material?.dispose();
this.pointTexture?.dispose();
diff --git a/app/components/particle-background/shape-samplers.ts b/app/components/particle-background/shape-samplers.ts
index 5c05127..3c298de 100644
--- a/app/components/particle-background/shape-samplers.ts
+++ b/app/components/particle-background/shape-samplers.ts
@@ -4,15 +4,8 @@ const SAMPLE_CANVAS_WIDTH = 1_800;
const SAMPLE_CANVAS_HEIGHT = 520;
const SAMPLE_FONT_SIZE = 240;
const SAMPLE_STEP = 3;
-const FONT_FAMILY = '"Instrument Sans Variable", "Helvetica Neue", Arial, sans-serif';
-
-async function waitForParticleFont() {
- if (!document.fonts) return;
- await Promise.all([
- document.fonts.ready,
- document.fonts.load(`700 ${SAMPLE_FONT_SIZE}px ${FONT_FAMILY}`),
- ]);
-}
+const FONT_FAMILY =
+ 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
function sampleWord(
context: CanvasRenderingContext2D,
@@ -70,9 +63,7 @@ function sampleWord(
};
}
-export async function sampleParticleWords(): Promise {
- await waitForParticleFont();
-
+export function sampleParticleWords(): SampledWords {
const canvas = document.createElement("canvas");
canvas.width = SAMPLE_CANVAS_WIDTH;
canvas.height = SAMPLE_CANVAS_HEIGHT;
diff --git a/app/globals.css b/app/globals.css
index 5f770a6..2006684 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -376,70 +376,6 @@ a:focus-visible {
text-wrap: balance;
}
-.home-hero {
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- min-height: clamp(540px, calc(100svh - 190px), 760px);
- padding: 14px 0 8px;
- gap: 64px;
-}
-
-.home-hero-intro {
- max-width: 420px;
- padding: 20px 0 0;
-}
-
-.home-question {
- margin: 0 0 10px;
- color: var(--accent-dark);
- font-family: var(--sans);
- font-size: 0.74rem;
- font-weight: 700;
- letter-spacing: 0.08em;
- text-transform: uppercase;
-}
-
-.home-thesis {
- margin: 0;
- color: var(--ink-strong);
- font-family: var(--body-serif);
- font-size: clamp(1.32rem, 2.4vw, 1.82rem);
- font-weight: 520;
- line-height: 1.26;
- letter-spacing: -0.026em;
- text-wrap: balance;
-}
-
-.home-summary-panel {
- align-self: flex-end;
- max-width: 620px;
- padding: 25px 28px;
- border: 1px solid var(--hairline);
- border-radius: var(--radius-medium);
- background: rgba(255, 253, 248, 0.84);
- box-shadow: var(--shadow-small);
- -webkit-backdrop-filter: blur(24px) saturate(115%);
- backdrop-filter: blur(24px) saturate(115%);
-}
-
-.home-summary-panel p {
- margin: 0 0 14px;
- color: var(--ink);
- font-family: var(--body-serif);
- font-size: 1.04rem;
- line-height: 1.66;
-}
-
-.home-summary-panel p:last-child {
- margin-bottom: 0;
- color: var(--accent-dark);
- font-family: var(--sans);
- font-size: 0.78rem;
- font-weight: 700;
- letter-spacing: 0.025em;
-}
-
.lede {
max-width: 680px;
margin: 24px 0 0;
@@ -1056,17 +992,6 @@ a:focus-visible {
padding: 32px 0 76px;
}
- .home-hero {
- min-height: 620px;
- gap: 54px;
- }
-
- .home-summary-panel {
- align-self: stretch;
- max-width: none;
- padding: 22px 24px;
- }
-
.current-chapter-intro {
padding-bottom: 38px;
}
@@ -1192,23 +1117,6 @@ a:focus-visible {
}
@media (max-width: 470px) {
- .home-hero {
- min-height: 560px;
- gap: 46px;
- }
-
- .home-hero-intro {
- padding-top: 8px;
- }
-
- .home-summary-panel {
- padding: 20px;
- }
-
- .home-summary-panel p {
- font-size: 1rem;
- }
-
.profile-sidebar {
grid-template-columns: 86px minmax(0, 1fr);
padding: 17px;
@@ -1349,8 +1257,7 @@ a:focus-visible {
}
.topbar-inner,
- .profile-sidebar,
- .home-summary-panel {
+ .profile-sidebar {
background: var(--paper-raised);
-webkit-backdrop-filter: none;
backdrop-filter: none;
@@ -1382,7 +1289,6 @@ a:focus-visible {
.domain-directory,
.archive-entry,
.practice-note,
- .home-summary-panel,
.current-chapter-brief {
background: var(--paper-raised);
border-color: var(--rule-strong);
diff --git a/app/home.module.css b/app/home.module.css
index 04f87f7..6fa48a0 100644
--- a/app/home.module.css
+++ b/app/home.module.css
@@ -5,6 +5,41 @@
padding: 0;
}
+:global(.site-shell--particle .topbar-inner) {
+ border-color: rgba(190, 177, 159, 0.62);
+ background: rgba(255, 253, 248, 0.82);
+ box-shadow:
+ 0 1px 2px rgba(52, 39, 29, 0.045),
+ 0 12px 34px rgba(52, 39, 29, 0.065);
+ -webkit-backdrop-filter: blur(28px) saturate(155%);
+ backdrop-filter: blur(28px) saturate(155%);
+}
+
+:global(.site-shell--particle .wordmark) {
+ color: var(--ink-strong);
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 0.96rem;
+ font-weight: 650;
+ font-optical-sizing: auto;
+ letter-spacing: -0.018em;
+ line-height: 1;
+}
+
+:global(.site-shell--particle .primary-nav a) {
+ color: #514a43;
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ font-size: 0.82rem;
+ font-weight: 570;
+ font-optical-sizing: auto;
+ letter-spacing: -0.008em;
+ line-height: 1;
+}
+
+:global(.site-shell--particle .primary-nav a.active) {
+ color: var(--accent-dark);
+ font-weight: 650;
+}
+
.particleStage {
position: relative;
display: flex;
@@ -270,6 +305,17 @@
}
@media (max-width: 720px) {
+ :global(.site-shell--particle .wordmark) {
+ font-size: 0.94rem;
+ font-weight: 650;
+ }
+
+ :global(.site-shell--particle .primary-nav a) {
+ font-size: 0.73rem;
+ font-weight: 600;
+ letter-spacing: -0.004em;
+ }
+
.particleStage {
min-height: calc(100vh - 122px);
min-height: max(22rem, calc(100svh - 122px));
@@ -384,7 +430,23 @@
}
}
+@media (prefers-reduced-transparency: reduce) {
+ :global(.site-shell--particle .topbar-inner) {
+ background: var(--paper-raised);
+ -webkit-backdrop-filter: none;
+ backdrop-filter: none;
+ }
+}
+
@media (prefers-contrast: more) {
+ :global(.site-shell--particle .topbar-inner) {
+ border-color: var(--rule-strong);
+ }
+
+ :global(.site-shell--particle .primary-nav a) {
+ color: var(--ink);
+ }
+
.profileSection {
border-color: var(--rule-strong);
background: var(--paper-raised);
diff --git a/scripts/check-published-copy-fixtures.mjs b/scripts/check-published-copy-fixtures.mjs
index abb9ff0..0951494 100644
--- a/scripts/check-published-copy-fixtures.mjs
+++ b/scripts/check-published-copy-fixtures.mjs
@@ -19,6 +19,7 @@ const isRootCommit = headCommitLine.split(/\s+/u).length === 1;
const protectedPathspecs = [
":(literal)app/education/page.tsx",
+ ":(literal)app/now/page.tsx",
":(literal)app/past-experience/page.tsx",
":(literal)app/past-experience/[slug]/page.tsx",
":(literal)app/past-experience/components/ExperienceDomainPage.tsx",
@@ -45,12 +46,12 @@ let comparisonMessage;
if (mergeBaseCheck.status === 0) {
revisions = [`${baseSha}...HEAD`];
comparisonMessage =
- "Protected Education and Past Experience sources are unchanged; published-copy snapshots contain additions only.";
+ "Protected Education, Past Experience, and Current Chapter sources are unchanged; published-copy snapshots contain additions only.";
} else if (isAuthorizedRootReset) {
if (mergeBaseCheck.status === 1) {
revisions = [baseSha, "HEAD"];
comparisonMessage =
- "Authorized root reset detected; direct tree comparison confirms protected sources are unchanged and published-copy snapshots contain additions only.";
+ "Authorized root reset detected; direct tree comparison confirms protected Education, Past Experience, and Current Chapter sources are unchanged and published-copy snapshots contain additions only.";
} else {
const emptyTreeSha = execFileSync(
"git",
@@ -59,7 +60,7 @@ if (mergeBaseCheck.status === 0) {
).trim();
revisions = [emptyTreeSha, "HEAD"];
comparisonMessage =
- "Authorized root reset detected without the previous object; protected sources and preservation snapshots are introduced as additions.";
+ "Authorized root reset detected without the previous object; protected Education, Past Experience, and Current Chapter sources and preservation snapshots are introduced as additions.";
}
} else {
throw new Error(
@@ -88,7 +89,7 @@ const forbidden = changes
if (forbidden.length > 0) {
throw new Error(
- "Protected Education and Past Experience sources are immutable, and published-copy snapshots are append-only; modification, deletion, and rename are forbidden:\n" +
+ "Protected Education, Past Experience, and Current Chapter sources are immutable, and published-copy snapshots are append-only; modification, deletion, and rename are forbidden:\n" +
forbidden.join("\n"),
);
}
diff --git a/tests/README.md b/tests/README.md
index 80901ae..ad32fa8 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -8,7 +8,8 @@ The suite verifies:
- the exact nine-route HTML manifest, canonicals, Open Graph URLs, robots, sitemap, and 404;
- consistent four-item primary navigation and internal asset/link resolution;
-- protected Education and Past Experience source hashes plus their approved counts;
+- protected Education, Past Experience, and Current Chapter source hashes plus
+ their approved counts;
- Home and Current Chapter copy, semantic fallback, and particle-system guardrails;
- byte-exact original identity-asset hashes;
- absence of retired routes, assets, phrases, and legacy runtime references.
diff --git a/tests/rendered-html.test.mjs b/tests/rendered-html.test.mjs
index 2d96f9a..3fff271 100644
--- a/tests/rendered-html.test.mjs
+++ b/tests/rendered-html.test.mjs
@@ -477,6 +477,43 @@ test("Education retains all 31 selected courses", async () => {
);
});
+test("README mirrors the current four-page site and its operating model", async () => {
+ const readme = await readFile(path.join(ROOT, "README.md"), "utf8");
+ assert.match(readme, /^# Theodore Ouyang$/mu);
+ for (const page of ["Home", "Education", "Past Experience", "Current Chapter"]) {
+ assert.match(readme, new RegExp(`\\b${page}\\b`, "u"), `README is missing ${page}`);
+ }
+ assert.match(readme, /particle[- ]first/iu);
+ assert.match(readme, /(?:same-shape|matching)\s+(?:particle\s+)?fallback/iu);
+ assert.match(readme, /Next(?:\.js)?\s+static export/iu);
+ assert.match(readme, /GitHub Pages/u);
+ assert.match(readme, /npm run check/u);
+ assert.match(readme, /npm run preview:static/u);
+ assert.match(
+ readme,
+ /\[Visit (?:the )?website[^\]]*\]\(https:\/\/www\.theodoreoy\.com\/\)/iu,
+ );
+ assert.doesNotMatch(readme, /Quis ego sum\?/iu);
+
+ const normalized = readme.toLowerCase();
+ for (const marker of RETIRED_MARKERS) {
+ assert.ok(!normalized.includes(marker), `Retired marker remains in README: ${marker}`);
+ }
+});
+
+test("the published-copy guard covers every protected source, including Current Chapter", async () => {
+ const guard = await readFile(
+ path.join(ROOT, "scripts", "check-published-copy-fixtures.mjs"),
+ "utf8",
+ );
+ for (const relative of PROTECTED_SOURCE_HASHES.keys()) {
+ assert.ok(
+ guard.includes(`:(literal)${relative}`),
+ `Published-copy guard is missing protected source: ${relative}`,
+ );
+ }
+});
+
test("original identity assets remain intact in source and export", async () => {
for (const [relative, expected] of IMMUTABLE_ASSET_HASHES) {
const source = path.join(ROOT, ...relative.split("/"));
@@ -486,7 +523,7 @@ test("original identity assets remain intact in source and export", async () =>
}
});
-test("the Home visual module preserves its bounded lifecycle and fallback contract", async () => {
+test("the Home visual module starts in a legible, bounded, static-first particle state", async () => {
const packageJson = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8"));
assert.equal(packageJson.dependencies.three, "0.160.0");
assert.equal(packageJson.devDependencies["@types/three"], "0.160.0");
@@ -495,12 +532,31 @@ test("the Home visual module preserves its bounded lifecycle and fallback contra
path.join(ROOT, "app", "components", "particle-background", "particle-config.ts"),
"utf8",
);
- assert.match(config, /desktopParticles:\s*4_000/u);
- assert.match(config, /mobileParticles:\s*2_200/u);
- assert.match(config, /reducedMotionParticles:\s*900/u);
+ assert.match(config, /initialShape:\s*"theodore"/u);
+ assert.match(config, /desktopParticles:\s*5_000/u);
+ assert.match(config, /mobileParticles:\s*2_800/u);
+ assert.match(config, /reducedMotionParticles:\s*1_600/u);
+ for (const parameter of [
+ /size:\s*2\.25/u,
+ /scatterOpacity:\s*0\.38/u,
+ /wordOpacity:\s*0\.96/u,
+ /minimumGray:\s*0\.18/u,
+ /maximumGray:\s*0\.38/u,
+ /redLift:\s*0\.018/u,
+ /blueDrop:\s*0\.012/u,
+ ]) {
+ assert.match(config, parameter);
+ }
+ assert.match(
+ config,
+ /PARTICLE_TIMELINE:[\s\S]*?\{ kind: "hold", shape: "theodore", duration: [\d.]+ \}/u,
+ );
const morphs = [...config.matchAll(/\{ kind: "morph", to: "([^"]+)", duration: ([\d.]+) \}/gu)];
- assert.deepEqual(morphs.map((match) => match[1]), ["theodore", "scatter", "ouyang", "scatter"]);
- assert.ok(morphs.every((match) => Number(match[2]) === 3), "Every morph must last three seconds");
+ assert.deepEqual(morphs.map((match) => match[1]), ["scatter", "ouyang", "scatter", "theodore"]);
+ assert.ok(
+ morphs.every((match) => Number(match[2]) === 2.8),
+ "Every morph must last 2.8 seconds",
+ );
const engine = await readFile(
path.join(ROOT, "app", "components", "particle-background", "particle-engine.ts"),
@@ -514,10 +570,48 @@ test("the Home visual module preserves its bounded lifecycle and fallback contra
"DynamicDrawUsage",
"requestAnimationFrame",
"setDrawRange",
+ "IntersectionObserver",
+ "ResizeObserver",
+ "handleViewportIntersection",
+ "handleElementResize",
+ "viewportVisible",
+ "renderCurrentFrame",
+ "syncAnimationState",
+ "resetToInitialShape",
+ "PARTICLE_CONFIG.initialShape",
+ "this.canvas.getBoundingClientRect()",
+ "detachRuntimeListeners",
+ "resizeObserver?.disconnect()",
+ "viewportObserver?.disconnect()",
"renderer?.dispose()",
]) {
assert.ok(engine.includes(contract), `Particle lifecycle contract is missing: ${contract}`);
}
+ const initializeBody = engine.match(
+ /async initialize\(\)\s*\{([\s\S]*?)\n\s*\}\n\n\s*private buildColors/u,
+ )?.[1];
+ assert.ok(initializeBody, "Particle initialize() body is missing");
+ assert.ok(
+ initializeBody.indexOf("this.renderCurrentFrame()") <
+ initializeBody.indexOf("this.syncAnimationState()"),
+ "The stable particle frame must render synchronously before animation can start",
+ );
+ assert.match(
+ engine,
+ /private syncAnimationState\(\)[\s\S]*?this\.reducedMotion[\s\S]*?!this\.viewportVisible[\s\S]*?this\.stop\(\)/u,
+ );
+ assert.match(
+ engine,
+ /handleMotionPreferenceChange[\s\S]*?resetToInitialShape\(\)[\s\S]*?this\.reducedMotion[\s\S]*?this\.stop\(\)[\s\S]*?this\.renderCurrentFrame\(\)/u,
+ );
+ assert.match(
+ engine,
+ /handleContextLost[\s\S]*?this\.unavailable = true[\s\S]*?detachRuntimeListeners\(\)[\s\S]*?this\.onUnavailable\(\)/u,
+ );
+ assert.match(
+ engine,
+ /const bounds = this\.canvas\.getBoundingClientRect\(\)[\s\S]*?event\.clientX - bounds\.left[\s\S]*?event\.clientY - bounds\.top/u,
+ );
const component = await readFile(
path.join(ROOT, "app", "components", "particle-background", "ParticleBackground.tsx"),
@@ -525,6 +619,18 @@ test("the Home visual module preserves its bounded lifecycle and fallback contra
);
assert.match(component, /await import\("\.\/particle-engine"\)/u);
assert.match(component, /engine\?\.dispose\(\)/u);
+ assert.match(component, /onUnavailable:\s*\(\)\s*=>\s*\{\s*if \(!cancelled\)/u);
+ assert.match(component, /const ready = await engine\.initialize\(\)/u);
+ assert.match(component, /setState\(ready \? "ready" : "fallback"\)/u);
+ assert.match(component, /]*className=\{styles\.fallbackName\}[^>]*>[\s\S]*?Theodore[\s\S]*?<\/span>/u);
+ assert.doesNotMatch(component, /Theodore Ouyang/u);
+
+ const sampler = await readFile(
+ path.join(ROOT, "app", "components", "particle-background", "shape-samplers.ts"),
+ "utf8",
+ );
+ assert.match(sampler, /system-ui/u);
+ assert.doesNotMatch(sampler, /document\.fonts/u);
const styles = await readFile(
path.join(ROOT, "app", "components", "particle-background", "ParticleBackground.module.css"),
@@ -532,7 +638,12 @@ test("the Home visual module preserves its bounded lifecycle and fallback contra
);
assert.match(styles, /@media \(prefers-reduced-motion: reduce\)/u);
assert.match(styles, /\.root\[data-state="fallback"\] \.fallbackName/u);
- assert.match(styles, /\.fallbackName\s*\{[\s\S]*?opacity:\s*1;/u);
+ assert.match(styles, /background-image:[\s\S]*?radial-gradient/u);
+ assert.match(styles, /(?:-webkit-)?background-clip:\s*text/u);
+ assert.match(
+ styles,
+ /@media \(prefers-reduced-motion: reduce\)[\s\S]*?\.root\[data-state="ready"\] \.canvas\s*\{[\s\S]*?opacity:\s*1;[\s\S]*?\.root\[data-state="ready"\] \.fallbackName\s*\{[\s\S]*?opacity:\s*0;/u,
+ );
});
test("legacy alternate runtimes stay out of the production artifact", async () => {
diff --git a/website-maintenance/01-page-file-map.md b/website-maintenance/01-page-file-map.md
index 119b6fb..726353c 100644
--- a/website-maintenance/01-page-file-map.md
+++ b/website-maintenance/01-page-file-map.md
@@ -6,7 +6,7 @@ Use this map to locate public pages without searching the repository.
| Public page | URL | Primary source |
|---|---|---|
-| Home — “Quis ego sum?” | `/` | `app/page.tsx` and `app/components/particle-background/` |
+| Home — particle stage and profile | `/` | `app/page.tsx`, `app/home.module.css`, and `app/components/particle-background/` |
| Education | `/education/` | `app/education/page.tsx` |
| Past Experience directory | `/past-experience/` | `app/past-experience/page.tsx` |
| All five Past Experience domains | `/past-experience//` | `app/lib/content/experience.ts`, `content/past-experience/archive-through-2026-06-30.md`, and `app/past-experience/[slug]/page.tsx` |
diff --git a/website-maintenance/02-repository-structure.md b/website-maintenance/02-repository-structure.md
index 3cde549..a76c29e 100644
--- a/website-maintenance/02-repository-structure.md
+++ b/website-maintenance/02-repository-structure.md
@@ -63,7 +63,7 @@ Pull-request validation and main-only GitHub Pages deployment.
## Maintenance rules
-1. Do not modify Education or Past Experience source text.
+1. Do not modify Education, Past Experience, or Current Chapter source text.
2. Add repeatable experience content through its registry; do not clone route wrappers.
3. Keep one canonical slug/path definition per collection.
4. Preserve original identity assets and generate derivatives through the script.
diff --git a/website-maintenance/03-home-particle-system.md b/website-maintenance/03-home-particle-system.md
index e1eb79e..78d9fe4 100644
--- a/website-maintenance/03-home-particle-system.md
+++ b/website-maintenance/03-home-particle-system.md
@@ -1,8 +1,9 @@
# Home Particle System
The Home page uses a progressive visual enhancement. Its HTML identity and
-biographical copy render without JavaScript; Three.js adds an `aria-hidden`
-background only after the client is ready.
+biographical copy render without JavaScript. A CSS point-pattern fallback gives
+the first browser paint the same visual language as the `aria-hidden` Three.js
+canvas that replaces it after the client is ready.
## Page composition
@@ -22,10 +23,12 @@ background only after the client is ready.
- `ParticleBackground.tsx` owns feature detection and lazy engine loading.
- `particle-engine.ts` owns Three.js allocation, animation, pointer response,
- visibility pausing, resizing, and disposal.
-- `shape-samplers.ts` converts text into point targets without network fonts.
-- `particle-config.ts` is the single source for point counts, timing, camera,
- and the `SCATTER → THEODORE → SCATTER → OUYANG` loop.
+ document and viewport visibility pausing, resizing, and disposal.
+- `shape-samplers.ts` converts text into point targets with the same platform UI
+ font stack as the CSS fallback, so first paint does not wait for or swap a web
+ font.
+- `particle-config.ts` is the single source for the initial target, point counts,
+ timing, camera, and the `THEODORE → SCATTER → OUYANG → SCATTER` loop.
- `ParticleBackground.module.css` owns the static fallback and canvas layers.
Its absolute layer is bounded to the opening viewport instead of following
the reader behind the biography and footer.
@@ -33,7 +36,11 @@ background only after the client is ready.
## Accessibility and failure behavior
- The decorative canvas is excluded from the accessibility tree.
-- `prefers-reduced-motion` renders one calm static field with no loop.
+- The engine builds and renders the `THEODORE` target before the canvas becomes
+ visible, so the first canvas frame is already stable rather than starting as
+ a loose scatter.
+- `prefers-reduced-motion` renders the same calm static `THEODORE` field with no
+ loop.
- Coarse pointers do not install repulsion interactions.
- Missing WebGL, context loss, or initialization failure leaves the HTML/CSS
fallback visible and the rest of the page usable.
@@ -43,9 +50,16 @@ background only after the client is ready.
Use one `Points` object, one buffer geometry, and typed arrays. Cap device pixel
ratio, lower point counts on narrower screens, pause while the document is
-hidden, and dispose all GPU resources and listeners on unmount. Do not add a
-second animation loop or allocate per-particle objects inside a frame.
+hidden or the opening stage is outside the viewport, and dispose the viewport
+observer, all GPU resources, and all listeners on unmount. Do not add a second
+animation loop or allocate per-particle objects inside a frame.
+
+Canvas sizing and pointer projection use the canvas bounding box rather than
+the browser window. A size change rebuilds the canonical word target and resets
+the loop to its stable first phase, preventing stretched glyphs after rotation
+or responsive viewport changes.
When changing the system, run the production build and inspect the static
-preview at 1440, 720, 390, and 320 CSS pixels. Also test reduced motion and a
-WebGL-disabled fallback before publishing.
+preview at 1440, 720, 390, and 320 CSS pixels. Confirm the first visible frame,
+scroll the stage fully offscreen to verify pausing, and test reduced motion and
+a WebGL-disabled fallback before publishing.
diff --git a/website-maintenance/04-release-checklist.md b/website-maintenance/04-release-checklist.md
index 2a31d9a..1bbeabb 100644
--- a/website-maintenance/04-release-checklist.md
+++ b/website-maintenance/04-release-checklist.md
@@ -26,15 +26,17 @@ Confirm:
- the HTML route manifest and sitemap match exactly;
- the four-item primary navigation appears consistently and has no retired route;
- the five Past Experience domains, 16 entries, 92 bullets, and 31 courses remain;
-- protected Education and Past Experience source hashes remain unchanged;
+- protected Education, Past Experience, and Current Chapter source hashes remain unchanged;
- internal links and responsive image sources resolve;
-- the Home canvas is decorative, the fallback is readable, and reduced motion is calm;
+- the Home first paint and first canvas frame are stable, the fallback is
+ readable, reduced motion is calm, and offscreen animation pauses;
- no retired route, asset directory, copy, or legacy runtime marker appears in `out/`.
Review `out/` with the in-app browser at 1440 × 900, 720 px, 390 × 844,
and 320 px. Check keyboard skip navigation, focus, mobile nav wrapping, text
-legibility over particles, pointer response on fine pointers, reduced motion,
-WebGL fallback, reduced transparency, and high contrast.
+legibility over particles, pointer response on fine pointers, first-frame
+stability, offscreen pausing, reduced motion, WebGL fallback, reduced
+transparency, and high contrast.
## Pull request and publishing
diff --git a/website-maintenance/05-architecture-decisions.md b/website-maintenance/05-architecture-decisions.md
index 545c280..b32c0a6 100644
--- a/website-maintenance/05-architecture-decisions.md
+++ b/website-maintenance/05-architecture-decisions.md
@@ -24,17 +24,25 @@ is rendered rather than silently discarded.
The particle field is a Home-only client enhancement. Three.js is dynamically
loaded outside the shared shell, while semantic identity and copy remain static
-HTML. A single GPU point cloud, capped pixel ratio, adaptive counts, document
-visibility pausing, and explicit disposal bound its cost. Reduced motion,
-coarse-pointer behavior, and WebGL failure all resolve to a static fallback.
+HTML. The first rendered canvas frame uses the stable `THEODORE` target. A
+single GPU point cloud, capped pixel ratio, adaptive counts, document and
+viewport visibility pausing, and explicit disposal bound its cost. Reduced
+motion resolves to a static particle target; coarse-pointer behavior and WebGL
+failure retain a usable fallback.
+
+The CSS fallback and canvas sampler share a platform UI font stack, so the
+fallback cannot change shape while a web font loads. Canvas resize and pointer
+math use the element's own bounds. Initialization returns an explicit success
+state; a lost WebGL context latches the system into fallback and detaches active
+runtime listeners rather than allowing a later ready state to overwrite it.
## Independent preservation boundary
-CI protects the Education and Past Experience source files directly, while the
-artifact suite verifies their normalized SHA-256 hashes and approved structural
-counts. Future published-copy snapshots are append-only. This lets an explicitly
-approved site change retire unrelated content without weakening the protected
-biographical record.
+CI protects the Education, Past Experience, and Current Chapter source files
+directly, while the artifact suite verifies their normalized SHA-256 hashes and
+approved structural counts. Future published-copy snapshots are append-only.
+This lets an explicitly approved site change retire unrelated content without
+weakening the protected biographical record.
## Image policy
diff --git a/website-maintenance/README.md b/website-maintenance/README.md
index 339fc16..e60dc6a 100644
--- a/website-maintenance/README.md
+++ b/website-maintenance/README.md
@@ -6,10 +6,10 @@ assistants.
## Non-negotiable guardrail
-Education and Past Experience source files are protected from modification.
-Published-copy snapshots are append-only. Any other removal or rewrite requires
-Theodore's explicit approval and must update routes, tests, and documentation in
-the same change.
+Education, Past Experience, and Current Chapter source files are protected from
+modification. Published-copy snapshots are append-only. Any other removal or
+rewrite requires Theodore's explicit approval and must update routes, tests,
+and documentation in the same change.
The visual system is intentionally restrained. Preserve its typography,
material hierarchy, responsive behavior, reduced-motion support, and immediate