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
| 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. |
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.
- Any metric with
projected: truerenders a Projected badge automatically. Keep projections labelled — never present a projection as a booked result. links.githubisnull, 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.pdfis 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
caseStudiesautomatically creates its/work/[slug]page, adds it to the sitemap.
| 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) |
npm install
npm run dev # http://localhost:3000npm 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 ordernpm run typecheck runs next typegen first because the PageProps / LayoutProps global
helpers are generated — a bare tsc --noEmit will fail without them.
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
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-faintis 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.
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.
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.
- Use
usePrefersReducedMotion()fromlib/hooks.ts, never motion/react'suseReducedMotion. The latter readsmatchMediaduring the hydration render, so any component branching on it emits different markup than the server did — a hydration error for every reduced-motion user. - Never fade in the
<h1>or the hero portrait. Chrome does not count an element first painted atopacity: 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. - 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.
ink-faintis decoration, never text. It sits at 2.4:1 on paper. This has been reintroduced on separator glyphs twice;ink-mutedis the dimmest step allowed to carry a character.
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.