Skip to content

Repository files navigation

Zuyu Liu — Personal Portfolio

A deep-tech portfolio. Work is rendered across three layers — physical, intelligence, human — rather than as a list of jobs.

The design brief is restraint: warm paper, near-black ink, two accents, and a lot of white space. Typography, the single photograph, the outcome figures and the margins carry it. It is meant to read like an industrial technology company's site, not a personal portfolio — so the default answer to "should we explain this / add an effect here" is no.

Live: https://zuyuliu.vercel.app


Stack

Concern Choice
Framework Next.js 16 (App Router, Turbopack, React 19)
Language TypeScript, strict mode
Styling Tailwind CSS v4 (CSS-first — there is no tailwind.config.js)
Animation CSS + one rAF loop. motion only on case-study routes. See Motion below.
Fonts Geist Sans + Geist Mono, self-hosted from app/fonts (app/fonts.ts)
Icons lucide-react
Backend None. No API keys, no database, no paid services.

Editing content

All copy, metrics, links, dates, and facts live in one file:

content/portfolio.ts

Components never hardcode content — they import from there. To update the site, edit that file.

Key exports:

Export What it drives
profile Name, headline, subhead, about, current direction, availability, portrait
links Email, LinkedIn, resume path, GitHub (null = hidden)
metrics The four proof numbers under the hero
caseStudies The four featured systems + their /work/[slug] pages
layers / capabilities The systems map — the site's one interaction
explorations Argonne, polymer Sankey, Credence, LoopEra
journey The background list
awards Recognition (folded into About)
site URL, SEO title/description, contact headline

caseStudy.whyItMattered is the one line each project gets on the homepage. Everything longer (summary, problem, ownership, approach, complexity) renders only on the case-study page. Keep that split: the homepage answers what, why and the proof — nothing else.

Conventions worth knowing

  • Any metric with projected: true renders a Projected badge automatically. Keep projections labelled — never present a projection as a booked result.
  • links.github is null, so no GitHub link renders anywhere. Set a real URL to enable it.
  • The phone number is deliberately not in this file and must never be displayed on the site.
  • public/Zuyu_Liu_Resume.pdf is a phone-free export of the source resume — the hosted file is public at a stable URL, so the contact line was reduced to email + LinkedIn. If you replace it, re-check that line before committing.
  • Adding a case study to caseStudies automatically creates its /work/[slug] page, adds it to the sitemap.

Routes

Route Rendering Purpose
/ Static Main narrative experience
/work/[slug] Static ×4 Detailed case studies (dynamicParams: false)
/Zuyu_Liu_Resume.pdf Asset Downloadable resume
/sitemap.xml Generated app/sitemap.ts
/robots.txt Generated app/robots.ts
/opengraph-image Generated Social share card (app/opengraph-image.tsx)
/icon.svg Asset ZL monogram favicon
Anything else Static Custom 404 (app/not-found.tsx)

Local development

npm install
npm run dev          # http://localhost:3000

Quality gates

npm run format       # prettier --write
npm run lint         # eslint (note: next build does NOT lint in Next 16)
npm run typecheck    # next typegen && tsc --noEmit
npm run build        # production build
npm run verify       # all four, in order

npm run typecheck runs next typegen first because the PageProps / LayoutProps global helpers are generated — a bare tsc --noEmit will fail without them.


Project structure

app/
  layout.tsx            Root layout: fonts, metadata, Person JSON-LD, skip link, chrome
  page.tsx              Homepage — composes the seven sections in order
  globals.css           Design tokens (@theme) + custom utilities + reduced-motion rules
  work/[slug]/page.tsx  Case-study detail route
  sitemap.ts robots.ts not-found.tsx opengraph-image.tsx icon.svg
components/
  chrome/               Nav, Footer, Monogram
  sections/             Hero · Proof · Work · SystemsMap · Background · About · Contact
  visuals/              CaseDiagram — the four project diagrams (case-study pages only)
  motion/               AmbientField · Parallax · Stagger
  ui/                   Section, Reveal, and shared primitives
content/portfolio.ts    ← all content
lib/                    hooks, class-name helper, layer colour mapping
public/                 Portrait + resume PDF

Design system

Tokens are defined in app/globals.css under @theme and become Tailwind utilities.

Token family Utilities Role
paper-* bg-paper, -raised, -sunken, -deep The dominant surface. Warm, never clinical white.
ink-* text-ink, -soft, -muted, -faint Type. See the warning below.
graphite bg-graphite The one dark band (contact + footer)
signal text-signal, -bright, -soft The accent. Deep electric blue, 9.3:1 on paper.
ember text-ember Rationed — see below. 5.06:1 on paper.

One accent does the work. signal carries every technical and interactive cue. ember is rationed to exactly two places: the "Projected" qualifier, and the single out-of-bounds element in each diagram. Reaching for ember anywhere else is a sign you wanted signal.

ink-faint is decoration only — 2.4:1 on paper, which fails WCAG AA. ink-muted (4.53:1) is the dimmest step allowed to carry words.

Custom utility classes: .shell (gutter), .section-y (the section rhythm — this is where the whitespace comes from), .label-mono, .display-xl/lg/md, .prose-body, .prose-lead, .enter, .enter-shift, .field, .ambient-light, .ambient-breathe, .fade-b, .plate, .deferred-paint.

Layer → colour lives in lib/layers.ts: physical = ember, intelligence = signal, human = ink. Used only inside the systems map and the diagrams.

Deliberately absent

Not to be reintroduced without a reason: the command palette, the custom cursor, the scroll-progress trace, film grain, accent glows, count-up number animations, hover-expand explanation panels, section index numbers, and travelling-dash animation on the diagrams.

Motion

Three systems, and nothing else.

System What it is Cost
Ambient AmbientField — a coordinate grid drifting one cell per 72s beneath illumination on a 44s cycle, plus a 7s .ambient-breathe pulse on system points. Zero JS. CSS keyframes in a Server Component.
Scroll reveal Reveal / Stagger — one IntersectionObserver per group flips data-shown; sequencing is nth-child transition delays. One observer per group.
Parallax Parallax — a rAF loop with exponential smoothing writing transform straight to the node. No React state per frame; the loop stops once settled.

Everything animates opacity and transform only, so it stays on the compositor. prefers-reduced-motion disables the ambient loops outright rather than freezing them on a final keyframe, and forces every [data-reveal] and [data-draw] to its end state.

Do not route this through an animation library. Using motion's useScroll/useSpring plus a motion component per revealed item cost ~450ms of script evaluation on a throttled phone, for behaviour that is two transitions and a delay. The homepage now ships no animation library at all — motion survives only in CaseDiagram, on case-study routes.

Four rules worth not breaking

  1. Use usePrefersReducedMotion() from lib/hooks.ts, never motion/react's useReducedMotion. The latter reads matchMedia during the hydration render, so any component branching on it emits different markup than the server did — a hydration error for every reduced-motion user.
  2. Never fade in the <h1> or the hero portrait. Chrome does not count an element first painted at opacity: 0, so fading the LCP element defers LCP to the end of the animation — the portrait cost 1.4s of render delay that way. Use .enter-shift (transform-only) for anything that could be the LCP element.
  3. Both font faces must stay preloaded. Deferring Geist Mono looks like a free critical-path win but its fallback has different metrics — it took CLS from 0 to 0.285.
  4. ink-faint is decoration, never text. It sits at 2.4:1 on paper. This has been reintroduced on separator glyphs twice; ink-muted is the dimmest step allowed to carry a character.

Deployment

Pushing to main triggers a Vercel deployment. No environment variables are required.

Do not add output: "export" — it would disable next/image optimisation and the generated OG image.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages