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() {
); 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