Set in Geist and Geist Mono by Vercel, released under the SIL Open Font License 1.1 (licence text). Both are self-hosted variable fonts — no font CDN, no third-party request — and the same faces render the social cards. Chinese text falls back to the system’s CJK fonts.
Hosting
Next.js (Pages Router) and React, built into one Docker image and run on a VPS in Tokyo.
Traffic arrives through a Cloudflare Tunnel: the server opens no inbound ports, and Cloudflare terminates TLS.
Content lives in SQLite on the server and is mirrored to Cloudflare R2 off-site; whole-site snapshots (database, uploads, history) are taken on top of that.
Pages are server-rendered or regenerated in the background when content changes, and every deploy is health-checked with an automatic rollback.
Self-assembling pages
/now shows what I write there when I have written something. Otherwise it assembles itself from data already on the site: current roles and studies from the public CV (entries hidden from the website never appear), the “currently coding” note from the home page, and the latest post and case study. Its status line gives the date the newest of those sources last changed, and the page regenerates within a minute of a change.
/uses works the same way: my own write-up first when there is one, then the stack above, read from package.json each time the page is generated.
Privacy & analytics
The site counts visits with its own first-party analytics. There are no cookies, no identifiers stored in your browser and no third-party analytics scripts.
Each page view records the path, the referring site (host name only), the country from Cloudflare’s country header, a coarse device and browser class, the page language and a visitor ID.
The visitor ID is a truncated hash of your IP address and browser user agent combined with a secret salt that is kept only in memory and replaced every day. It cannot be reversed, and the same visitor gets a different ID the next day. Your IP address and user agent are never stored.
If your browser sends Do Not Track (DNT: 1) or Global Privacy Control (Sec-GPC: 1), nothing is recorded. Bots and my own visits are not counted either.
Real-user performance timings (Core Web Vitals: LCP, INP, CLS, FCP, TTFB) are recorded per page with the device class only — no visitor ID.
The data stays in a separate database file on the server, is never part of the content backups or the R2 mirror, and is deleted after at most 400 days.
Build
This page was generated on from commit 82f0ce0. The source is on GitHub.
Changelog
2.2.0
A research-driven round (benchmarks of leading developer portfolios and CMS admins, live visual/performance/accessibility/SEO audits) on a new platform: Next.js 15.5 and patched dependencies.
Security
Next.js 14.2 → 15.5.27 (Pages Router, React 18): closes the image
optimizer RCE/DoS, rewrite SSRF/smuggling and SSG/ISR cache-poisoning
advisories. On-request image optimization is disabled (/_next/image →
404); uploads are resized and converted at upload time instead.
Tiptap 3.31.3, prosemirror-view 1.42.6, nodemailer 10.0.12, sharp 0.35.5
and resolutions for vulnerable transitive packages; CI audit gate with a
reviewed allowlist (build-time tooling only).
MDX renders from a server-built tree without eval; the production CSP
no longer allows 'unsafe-eval'. Embeds are an allow-list (YouTube,
Vimeo, figures).
Every server-side fetch of a content- or admin-supplied URL goes through
lib/security/safeFetch (public addresses only, DNS pinned, redirects
re-checked, size and time limits).
Uploaded and imported images lose their EXIF / XMP (GPS, camera serials)
— losslessly when no resize or rotation is needed — and images that decode
to more than 80 MP are refused with 413.
Compiled articles and the feeds only carry safe link and image URLs
(javascript: / data: are removed); embeds accept player URLs only.
Signing out also ends Draft Mode, and the Draft bar's Exit works without a
session.
Analytics records article paths only for published slugs and gives each
client and network a bounded share of the event budget.
The tracked CV seed holds only public content; an old snapshot is no
longer tracked.
Added
Typography & motion: real Geist Sans/Mono (two preloads, ~141 KB),
balanced headings and tabular figures; circular theme reveal from the
toggle; directional page transitions with a pinned header; blur-rise hero
headline; cursor-lit card borders and hover-intent lists; scroll-driven
experience rail; shared-element photo lightbox; number roll-ups. All
transform/opacity only and off under reduced motion.
Home: faster hero (pre-baked responsive WebP), Skills from the CV,
writing & case-study links, full-width Field Notes, GitHub data rendered
in the HTML, indigo contribution graph.
Command palette 3.0: loads on demand, searches posts, case studies
and CV entries, owner actions (inbox, backup, log out), vCard and booking.
Articles: GFM tables/strikethrough, zero-CLS responsive images,
refined reading layout, full-content /feed.xml, /work/feed.xml,
/feed.json, Draft Mode with an Edit pill.
Pages: /cv/zh as a real Chinese page (title, description, JSON-LD),
per-entry CV anchors, contact availability card, vCard and QR, /colophon
generated from the repository.
Authoring: local drafts and conflict-safe saves for every editor,
item history and undo-delete, compile-checked publishing with a checklist,
side-by-side live preview, slash menu, upload-time WebP variants and safe
remote image import.
Admin: status hub with "Needs attention", Save and leave dialog,
keyboard shortcuts overlay, sortable home cards, CV/home validation, and
first-party cookieless Analytics with real-user Web Vitals.
Operations: scripts/deploy.sh (bundle-based deploy with backups,
health/warm-up gating, smoke test and automatic rollback), scheduled
change-aware backups, structured JSON logs with request ids, Playwright +
axe browser tests and a performance budget in CI.
Changed
Shared JavaScript 225 → 204 kB (_app 137 → 114 kB): palette, drawer and
below-the-fold widgets load on demand; brand icons are inline SVG.
docker-compose.override.yml is no longer tracked; copy
docker-compose.override.example.yml on a new server.
Lint runs through the ESLint CLI over the whole repository.
Upload variants keep the original's extension (photo.jpg.w640.webp), so
originals with the same stem never share variants.
Fixed
Lists, quotes and the slash menu's list items in the editor (a duplicate
prosemirror-model broke wrapping and list splits).
A post with an image inside a sentence no longer fails hydration; inline
code wraps instead of being clipped on narrow screens.
RSS publish times match the JSON Feed and structured data.
Keyboard focus no longer dims the other rows of hover lists; the hero name
appears at once under reduced motion; the lightbox allows pinch-zoom.
The vCard no longer falls back to the built-in name when the owner hides
the name fields.
scripts/deploy.sh: a live analytics write during the backup no longer
aborts the deploy, --force on the same commit recreates the container,
and a dropped connection during the build restores the server.
2.1.0
Hardening and polish on top of 2.0.0, after two adversarial reviews (81 confirmed findings fixed) and an end-to-end check against a copy of the live content store.
Added
Admin Inbox (/admin/dashboard?tab=inbox, Alt/⌃+5): every contact
message is stored with whether it was emailed — readable and answerable
(reply-by-email with the original quoted) even when SMTP is not
configured. Read/unread, delete, unread badge, keyboard navigation.
Site-wide daily and size caps; full-site backups include it and restores
merge it without bringing deleted messages back.
CV Studio: hidden-items panel (every hidden section/entry/field/bullet
in EN and 中, jump-to and one-click Show), A4 CV / Website preview,
alignment badges for linked EN↔中 lists, drafts that remember the version
they were based on.
Admin-only reads with optimistic concurrency: GET /api/admin/cvdata
and /api/admin/home return the full documents with a version ETag;
saves send If-Match and get 409 instead of silently overwriting newer
edits. Public GET /api/cvdata / /api/home always return the pruned
view, even to a signed-in admin.
Mobile CV reading mode with a sticky Download PDF / EN·中文 / Copy link
bar; self-updating /now and /uses built from the public CV, the
currently-coding note, the latest writing and the curated stack.
Articles: heading anchors, sticky table of contents, reading progress,
prev/next and related items; command palette 2.0 (fuzzy ranking,
recents, in-page jumps, CV / theme / link actions).
On-demand revalidation: saving a post, case study, page, CV or home
page refreshes the affected static pages immediately; a boot-time warm-up
(scripts/warm-isr.mjs) replaces the image's build-time pages before
traffic arrives.
Persistent header/footer across navigation, native view transitions,
real PWA icons, AA-contrast accent tokens.
Fixed
next build failed with "database is locked" when the store did not exist
yet (always the case in the Docker build).
Next's res.json() replaced version ETags with a body hash, so every
conditional save conflicted (lib/http.tssendVersionedJson).
Editors could load the pruned public CV after a session expired and save
it back, deleting hidden content.
A full-backup restore was undone on the next boot; public pages kept
reading the old database after a restore; failed R2 writes could be
reverted by the boot sync (r2_pending now repairs them).
Hidden end dates showed as "Present", hidden expiry dates as "No Expiry";
linked hide toggles could hit the wrong 中 entry; "Sync 中 ← EN" dropped
中-only hides; duplicating a hidden entry made it public.
Home page layout shift on phones (CLS 0.28–0.48 → none); light-mode dark
flash; portals without the site fonts; /cv language not following client
navigation; social cards blocked by robots.txt.
Security
Middleware refuses cross-site and non-JSON writes to admin/content APIs.
Login: per-(email, IP) lockout plus a real site-wide throughput cap under
attack, a signed known-device cookie for the owner, identical responses
for unknown e-mail vs wrong password, async scrypt.
Logout and password reset revoke every issued session.
Client-IP headers trusted only from loopback and the container's default
gateway unless TRUSTED_PROXY_IPS is set.
Contact form: the sum is issued and verified by the server (HMAC bound to
the single-use nonce); JSON-LD escaped against script breakout; one
upload-name rule for upload, serving and backups.
2.0.0
Added
CV visibility at every level. Hide / show any section (separately
for the CV/PDF and for the website), any entry (degree, job, project,
highlight, certificate…), any field (e.g. Education → GPA, phone,
email) and any single bullet or tag. Hidden content is removed
server-side from /, /about, /cv and the unauthenticated
GET /api/cvdata (lib/cvVisibility.ts, readPublicCvData()).
CV Studio rewrite (components/Admin/CVEditorStudio.tsx,
components/Admin/cvStudio/*): schema-driven forms for every section
(lib/cvSections.ts), per-field eye toggles, collapsible entry cards
with drag-and-drop / duplicate / delete-with-undo, bullet editor
(Enter = new bullet, Backspace on empty = remove, Alt+↑/↓ = move,
multi-line paste), tag input, month pickers with "Present", one undo
history for EN + 中 with typing coalesced per field, EN ↔ 中 linked
visibility & structure, in-editor "Sync 中 ← EN" (undoable), local
draft recovery, overwrite guard when the CV changed elsewhere, live
fit-to-width A4 preview with page-break guides, responsive panes.
Patents and Languages sections now render on the A4 CV and on
/about (patents data existed but was never shown).
/cv?lang=zh and /cv?print=1; language-aware dates (Sep 2024 /
2024.09), labels (至今, 预计, 电话, 邮箱) and Chinese name order.
Curated, grouped tech stack (/api/stack) instead of a raw dependency
dump; copy button on MDX code blocks; real G-then-key navigation
shortcuts in the command palette.
Fixed
Studio wrote Awards to competitionAwardsList and Extra Skills to
extraSkills (the viewer reads awards / points), so those edits
never appeared; missing editors for highlights, related skills,
certificate details and patents.
Header: menu missing on phones for 7 routes and on all tablets; sticky
header never stuck; links did full page reloads; Dashboard / Logout
never appeared (httpOnly cookie read from JS); the "last commit" pill
wrapped onto three lines beside the full nav.
Affiliation logos overflowed the page horizontally on tablets (fixed
160px chips in a 4-column grid).
Dashboard mounted every editor at once — the Studio's ⌘S/⌘Z fired on
other tabs and editors overwrote each other's saves.
Image uploads over ~750 KB failed (1 MB body limit); same-name uploads
overwrote each other; PNG logos lost transparency.
Home editor could wipe the home page if its load failed; upload
callbacks reverted edits made during the upload.
Raw JSON editor rewrote every curly quote in CV text on save.
Version restore didn't snapshot the current state first; snapshot times
were shown in the wrong timezone.
Backup restore hung on a corrupt archive; non-atomic DB swap; local
snapshot path check could be bypassed.
MongoDB backup/restore used stale seed files instead of the live store
(restores silently changed nothing); image restore allowed path
traversal; connection string returned to the browser and cached in
localStorage.
MDX frontmatter dates crashed static generation; deleted posts could be
resurrected from disk seeds or a stale R2 copy.
Floating section nav targeted IDs that didn't exist; hero typewriter
leaked timers; a marginTop: 320px hack triggered by any URL containing
"edit"; About hero hook-order bug; syntax highlighting colours never
applied.
Blog/work editors: tags couldn't contain commas/spaces while typing,
fields editable before load, "New" could overwrite an existing slug.
Security
Login lockout is now per (account, client IP) with a global breaker,
so a stranger can no longer lock the admin out; failures against a
wrong email no longer count. Admin email compared case-insensitively.
One shared admin-auth helper with the JWT algorithm pinned (HS256).
Rate limits keyed per route + IP (one endpoint can't exhaust another's
budget), bounded memory, optional TRUSTED_PROXY_IPS.
Contact nonces audience-scoped; contact form returns an error when a
message could be neither mailed nor stored; mailer no longer logs
bodies (reset links); SMTP timeouts.
Reset-password validates token / password before consuming the token.
Uploads: AVIF brand check, never overwrite, served with a sandboxing
CSP; CSP drops unused hCaptcha / Binance hosts; production browser
source maps disabled.
Changed
Docker: named volumes for /app/data and /app/public/uploads (admin
password changes, history, backups and uploads now survive redeploys);
NEXT_PUBLIC_* passed as build args; runtime state excluded from the
build context. CI on Node 22. Boot-time R2 sync keeps newer local edits.
GitHub proxies: fetch timeouts, shared in-flight requests, short
failure caching, CDN cache headers; stats hide instead of showing zeros.
Accessibility: skip link + <main> landmark, one h1 per page,
labelled icon buttons, accessible carousel (pause / play, keyboard,
captions as alt text), combobox semantics in the command palette,
prefers-reduced-motion honoured by every animation, AA contrast fixes.
Removed 38 unused dependencies (MUI, FontAwesome, Formik, axios,
next-auth, i18n stack, styled-components, luxon, …) and ~40 dead files;
lint is at zero warnings.