diff --git a/AGENTS.md b/AGENTS.md
index c616d05..d19a2ba 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -2,7 +2,7 @@
Instructions for **AI coding agents** (Cursor, Claude Code, Codex, Copilot, etc.) and anyone running **automated** edits against this repository.
-**Human-oriented** contributor docs: [CONTRIBUTING.md](CONTRIBUTING.md). **Full project reference:** [README.md](README.md) (architecture, APIs, environment variables, CI, MDX, deployment).
+**Human-oriented** contributor docs: [CONTRIBUTING.md](CONTRIBUTING.md). **Full project reference:** [README.md](README.md) (architecture, APIs, environment variables, CI, deployment).
---
@@ -98,7 +98,7 @@ Run `git status` and `git diff --staged` before committing.
| Topic | Document |
| --- | --- |
-| APIs, env, CI, MDX, fork checklist | [README.md](README.md) |
+| APIs, env, CI, fork checklist | [README.md](README.md) |
| Human contribution process | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Short Claude Code stack summary | [CLAUDE.md](CLAUDE.md) |
| Security reporting | [SECURITY.md](SECURITY.md) |
diff --git a/CLAUDE.md b/CLAUDE.md
index 5d43c85..2aa3d0f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -19,10 +19,11 @@ Canonical documentation: **[README.md](README.md)** (setup, routes, APIs, env, C
| **Now playing** | `GET /api/spotify/now-playing` — Spotify Web API; optional Supabase writes (`listening_*`) with service role |
| **Client polling** | `app/hooks/use-now-playing.ts` — polls every **10s** (`cache: "no-store"`); API `Cache-Control: public, s-maxage=10, stale-while-revalidate=5` |
| **GitHub** | `GET /api/github/contributions` — GraphQL calendar; pinned repos (incl. `stargazerCount` / `isArchived`) + static fallback in `app/lib/github-pinned.ts` |
-| **Weather** | `GET /api/weather` — Open-Meteo (Berkeley, CA); current temp, **feels-like**, **humidity**, condition, rain chance; `next.revalidate = 600`; local clock in card via `app/components/berkeley-time.tsx` (`America/Los_Angeles`) |
+| **Weather** | `GET /api/weather` — Open-Meteo (Berkeley, CA); current temp, **feels-like**, **humidity**, **rain chance**; `next.revalidate = 600`; local clock in card via `app/components/berkeley-time.tsx` (`America/Los_Angeles`) |
| **Home UI** | Identity + **social links** (brand-colored hovers), **Listening** / **Location** cards — `app/components/listening-card.tsx`, `weather-card.tsx` |
+| **Nav** | **About · Projects · Misc** — `app/components/nav.tsx` (active link accent + underline) |
+| **Footer** | `app/components/site-footer.tsx` in root layout — every page |
| **Typography** | Geist `font-sans` on `body`; **Nunito** for nav + `.mag-card` / `.mag-label` (`app/globals.css`) |
-| **Notes** | External Notion page linked from navigation |
| **Observability** | Sentry via `instrumentation*.ts` + `sentry.*.config.ts` (DSN optional); Vercel Analytics / Speed Insights in root layout |
| **Theme** | `app/components/theme-provider.tsx` + inline script in `app/layout.tsx` — default **light** when unset |
@@ -44,7 +45,7 @@ npm run lint && npm run typecheck && npm run test && npm run build
- [`lib/spotify-now-playing-helpers.ts`](lib/spotify-now-playing-helpers.ts) — pure helpers used by that route (tested)
- [`lib/listening-supabase.ts`](lib/listening-supabase.ts) — optional Supabase reads/writes for listening history
- [`lib/weather-open-meteo.ts`](lib/weather-open-meteo.ts) — Open-Meteo payload parsing (tested)
-- [`app/lib/substack.ts`](app/lib/substack.ts) — RSS fetch + parse (tested)
+- [`app/components/pinned-project-link.tsx`](app/components/pinned-project-link.tsx) — shared pinned-repo markup (home list + projects cards)
- [`app/page.tsx`](app/page.tsx) — home layout, social URLs, cards
- [`app/globals.css`](app/globals.css) — `.mag-card`, `.mag-label`, theme surfaces
- [`next.config.ts`](next.config.ts) — image remote patterns, `withSentryConfig`
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cd94913..2fec594 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -115,7 +115,7 @@ If you touch **OAuth callbacks**, **API routes**, or **Supabase policies**, coor
## Forking for your own site
-See the **Forking this project** section in [README.md](README.md) for a checklist of files to replace (Spotify app, GitHub repos, Supabase, Substack, etc.).
+See the **Forking this project** section in [README.md](README.md) for a checklist of files to replace (Spotify app, GitHub repos, Supabase, etc.).
---
diff --git a/README.md b/README.md
index fa0bf9d..e16e450 100644
--- a/README.md
+++ b/README.md
@@ -89,14 +89,14 @@ To do that, the site mixes several kinds of content:
| Kind | Purpose | Examples on this site |
| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Narrative** | Background and intent that change slowly | About page (education, focus, experience, volunteering); project write-ups |
-| **Live signals** | Small windows into the present | Spotify **now playing** (polled every ~10s); Berkeley **weather + local clock**; GitHub **pinned repos** and **contribution calendar**; Substack **latest posts** via RSS |
-| **Curated lists** | Taste and attention, hand-picked | Misc page (news, tools & creators, remembrance) |
-| **External writing** | Longer thinking lives elsewhere | **Notes** on Notion and **Blog** on Substack — linked from nav, not duplicated here |
+| **Live signals** | Small windows into the present | Spotify **now playing** (polled every ~10s); Berkeley **weather + local clock**; GitHub **pinned repos** and **contribution calendar** |
+| **Curated lists** | Taste and attention, hand-picked | Misc page (**Watching**, tools & creators, remembrance) |
+| **External writing** | Longer thinking lives elsewhere | Substack and Notion linked from social / JSON-LD — not duplicated or RSS-fed on the home page |
Visually, the site uses an **editorial / magazine** layout: cream-toned cards (`.mag-card`), Nunito for nav and card chrome, Geist Sans for body text, light mode by default with full dark-mode support. The goal is reading comfort and personality — a hub you can revisit, not a feature dashboard.
-This repository implements that idea with **Next.js 16** (App Router), **React 19**, **TypeScript**, **Tailwind CSS 4**, deployed on **Vercel**. Dynamic data comes from **Spotify**, **GitHub GraphQL**, **Open-Meteo**, optional **Supabase** listening history, and **Substack RSS**. Optional observability: **Sentry**, **Vercel Analytics**, **Speed Insights**.
+This repository implements that idea with **Next.js 16** (App Router), **React 19**, **TypeScript**, **Tailwind CSS 4**, deployed on **Vercel**. Dynamic data comes from **Spotify**, **GitHub GraphQL**, **Open-Meteo**, and optional **Supabase** listening history. Optional observability: **Sentry**, **Vercel Analytics**, **Speed Insights**.
There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rendered directly by the App Router.
@@ -105,12 +105,10 @@ There is **no** `middleware.ts` / `proxy.ts` — all routes are public and rende
| Route / link | Role |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `**/`** | First impression — identity, Giving What We Can pledge mark, social links (Email, Signal, GitHub, LinkedIn, Spotify), **Listening** card (Spotify), **Location** card (Open-Meteo weather + `America/Los_Angeles` clock, °C/°F toggle), pinned GitHub repos, Substack headlines |
-| `**/about`** | Deeper self — personality, education, current **Focus**, experience, volunteering |
-| `**/projects`** | Work in the world — GitHub pinned project cards (stars / archived badges) + contribution heatmap |
-| `**/misc**` | Side of the mind — curated **News**, **Things I Love** (tools & creators), **Remembrance** |
-| **Notes** (nav) | External Notion site — longer notes, not hosted in this repo |
-| **Blog** (nav) | External Substack — essays linked from home RSS snippets |
+| `**/`** | First impression — identity, Giving What We Can pledge mark, social links (Email, Signal, GitHub, LinkedIn, Spotify), **Listening** card (Spotify), **Location** card (Open-Meteo weather + `America/Los_Angeles` clock, °C/°F toggle), pinned GitHub repos |
+| `**/about`** | Deeper self — personality, education, current **Focus**, experience, volunteering |
+| `**/projects`** | Work in the world — GitHub pinned project cards (stars / archived badges) + contribution heatmap |
+| `**/misc**` | Side of the mind — curated **Watching**, **Things I Love** (tools & creators), **Remembrance** |
**Open Graph:** each in-app route has an `opengraph-image` handler (`[app/lib/og.tsx](app/lib/og.tsx)`). **SEO:** `sitemap.ts`, `robots.ts`, and JSON-LD `Person` on the home page.
@@ -196,9 +194,9 @@ Pinned versions: `[package.json](package.json)`.
```text
kaichen.dev/
├── app/
-│ ├── layout.tsx # Root layout, metadata, theme, Nav, Analytics
-│ ├── page.tsx # Home (identity, Spotify, weather, GitHub, Substack)
-│ ├── globals.css # Theme tokens, .mag-card, dark mode
+│ ├── layout.tsx # Root layout, metadata, theme, Nav, SiteFooter, Analytics
+│ ├── page.tsx # Home (identity, Spotify, weather, GitHub)
+│ ├── globals.css # Theme tokens, .mag-card, animations
│ ├── opengraph-image.tsx # OG for /
│ ├── about/ # Bio, education, focus, experience + OG
│ ├── projects/ # Pinned repos, contribution heatmap + OG
@@ -207,9 +205,9 @@ kaichen.dev/
│ │ ├── spotify/now-playing/
│ │ ├── github/contributions/
│ │ └── weather/
-│ ├── components/ # nav, listening-card, weather-card, theme, …
+│ ├── components/ # nav, site-footer, listening-card, weather-card, theme, …
│ ├── hooks/use-now-playing.ts
-│ ├── lib/ # og.tsx, substack.ts, github-pinned.ts
+│ ├── lib/ # og.tsx, github-pinned.ts
│ ├── sitemap.ts
│ └── robots.ts
├── lib/ # Server helpers + Vitest tests
@@ -233,13 +231,13 @@ kaichen.dev/
| Route | What it does |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `/` | Identity + GWWC mark; social links; **Listening** (Spotify via `[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`, ~10s poll, `cache: "no-store"`); **Location** (Open-Meteo via `GET /api/weather`, °C/°F, feels-like, humidity, rain chance, `[berkeley-time.tsx](app/components/berkeley-time.tsx)`); pinned GitHub repos (`[github-pinned.ts](app/lib/github-pinned.ts)`, GraphQL + static fallback); Substack RSS (`[substack.ts](app/lib/substack.ts)`) |
-| `/about` | Personality, education, Focus, experience, volunteering |
-| `/projects` | Pinned project cards + GitHub contribution calendar (`[/api/github/contributions](app/api/github/contributions/route.ts)`) |
-| `/misc` | News, Things I Love, Remembrance |
+| `/` | Identity + GWWC mark; social links; **Listening** (Spotify via `[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`, ~10s poll, `cache: "no-store"`); **Location** (Open-Meteo via `GET /api/weather`, °C/°F, feels-like, humidity, rain chance, `[berkeley-time.tsx](app/components/berkeley-time.tsx)`); pinned GitHub repos (`[github-pinned.ts](app/lib/github-pinned.ts)`, GraphQL + static fallback) |
+| `/about` | Personality, education, Focus, experience, volunteering |
+| `/projects` | Pinned project cards + GitHub contribution calendar (`[/api/github/contributions](app/api/github/contributions/route.ts)`) |
+| `/misc` | Watching, Things I Love, Remembrance |
-**External nav:** **About · Projects · Notes · Blog · Misc** — Notes → Notion, Blog → Substack (no `/notes` or `/blog` in-repo).
+**Nav:** **About · Projects · Misc** — site footer on every page: `© 2026 Kai Thomas Chen. All rights reserved.`
#### API routes
@@ -249,6 +247,7 @@ All handlers under `app/api/`.
| Method & path | Behavior | Caching / env |
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
| `GET /api/spotify/now-playing` | Spotify `currently-playing` + `recently-played`; optional Supabase `listening_`* writes while playing; in-memory `lastKnownTrack` fallback | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`; `SPOTIFY_*`; optional `SUPABASE_*` |
+| `GET /api/spotify/last-month-top` | Previous calendar month top **5** tracks from Supabase `listening_history` (`recorded_at`, paginated) | `revalidate = 600`; optional `SUPABASE_*` |
| `GET /api/github/contributions` | GraphQL contribution calendar (12 months) → `{ weeks, totalContributions }` | `revalidate = 300`; `GITHUB_TOKEN` |
| `GET /api/weather` | Open-Meteo current conditions for Berkeley, CA (`[weather-open-meteo.ts](lib/weather-open-meteo.ts)`) | `revalidate = 600`; no API key |
@@ -286,7 +285,6 @@ vercel env pull .env.vercel.check # gitignored — do not commit
| **GitHub GraphQL** | Contribution calendar + pinned repos (stars, archived) |
| **Open-Meteo** | Berkeley weather (no key) |
| **Supabase** | Optional listening history persistence |
-| **Substack RSS** | Home page latest posts |
#### Local development tips
@@ -301,7 +299,7 @@ vercel env pull .env.vercel.check # gitignored — do not commit
#### Testing
-Vitest unit tests in `lib/*.test.ts` — Substack RSS, weather parsing, Spotify helpers.
+Vitest unit tests in `lib/*.test.ts` — weather parsing and Spotify helpers.
```bash
npm run test
@@ -339,7 +337,6 @@ vercel --prod # after vercel link
| Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*` in `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` |
| GitHub login / pins | `GITHUB_LOGIN`, `[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`, contributions route |
| Supabase schema | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`, now-playing route |
-| Substack feed | `[app/lib/substack.ts](app/lib/substack.ts)` |
| Weather location | `[app/api/weather/route.ts](app/api/weather/route.ts)` |
| Misc lists | `[app/misc/page.tsx](app/misc/page.tsx)` |
| Theme / fonts | `app/layout.tsx`, `app/globals.css`, `theme-provider.tsx` |
@@ -410,14 +407,14 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`.
| 类型 | 作用 | 在本站的具体体现 |
| -------- | ---------------- | -------------------------------------------------------------------------------------------------- |
| **叙述** | 变化较慢的背景与方向 | About(教育、Focus、经历、志愿);Projects 项目说明 |
-| **实时信号** | 照进「此刻」的小窗口 | Spotify **正在播放**(约每 10s 轮询);Berkeley **天气 + 本地时钟**;GitHub **置顶仓库**与**贡献日历**;Substack **最新文章**(RSS) |
-| **策展列表** | 品味与注意力,人工挑选 | Misc(新闻、工具与创作者、纪念) |
-| **站外长文** | 更长思考托管 elsewhere | 导航链至 **Notion Notes** 与 **Substack Blog**,不在本仓库重复搬运 |
+| **实时信号** | 照进「此刻」的小窗口 | Spotify **正在播放**(约每 10s 轮询);Berkeley **天气 + 本地时钟**;GitHub **置顶仓库**与**贡献日历** |
+| **策展列表** | 品味与注意力,人工挑选 | Misc(**Watching**、工具与创作者、纪念) |
+| **站外长文** | 更长思考托管 elsewhere | Substack / Notion 通过社交链接与 JSON-LD 引用 —— 首页不展示 RSS 摘要 |
视觉上采用**杂志 / editorial** 排版:奶油色卡片(`.mag-card`)、Nunito 用于导航与卡片 chrome、Geist Sans 用于正文、默认浅色并完整支持暗色。目标是阅读舒适与个性 —— 可回访的**枢纽**,而非功能控制台。
-技术实现:**Next.js 16**(App Router)、**React 19**、**TypeScript**、**Tailwind CSS 4**,部署于 **Vercel**。动态数据来自 **Spotify**、**GitHub GraphQL**、**Open-Meteo**、可选 **Supabase** 听歌记录、**Substack RSS**。可选观测:**Sentry**、**Vercel Analytics**、**Speed Insights**。
+技术实现:**Next.js 16**(App Router)、**React 19**、**TypeScript**、**Tailwind CSS 4**,部署于 **Vercel**。动态数据来自 **Spotify**、**GitHub GraphQL**、**Open-Meteo**、可选 **Supabase** 听歌记录。可选观测:**Sentry**、**Vercel Analytics**、**Speed Insights**。
本仓库**没有** `middleware.ts` / `proxy.ts`,所有路由公开,由 App Router 直接渲染。
@@ -426,12 +423,10 @@ Keep **GPL-3.0** compliance if you redistribute — see `[LICENSE](LICENSE)`.
| 路由 / 链接 | 作用 |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `**/`** | 第一印象 —— 身份、Giving What We Can 标识、社交链接(Email、Signal、GitHub、LinkedIn、Spotify)、**Listening** 卡片(Spotify)、**Location** 卡片(Open-Meteo 天气 + `America/Los_Angeles` 时钟、°C/°F 切换)、GitHub 置顶仓库、Substack 摘要 |
+| `**/`** | 第一印象 —— 身份、Giving What We Can 标识、社交链接(Email、Signal、GitHub、LinkedIn、Spotify)、**Listening** 卡片(Spotify)、**Location** 卡片(Open-Meteo 天气 + `America/Los_Angeles` 时钟、°C/°F 切换)、GitHub 置顶仓库 |
| `**/about`** | 更深的自我 —— 性格、教育、当前 **Focus**、经历、志愿 |
| `**/projects`** | 留在世界上的工作 —— GitHub 置顶项目(star / archived 徽章)+ 贡献热力图 |
-| `**/misc**` | 心智侧面 —— 策展 **News**、**Things I Love**(工具与创作者)、**Remembrance** |
-| **Notes**(导航) | 外链 Notion —— 长笔记,不在本仓库托管 |
-| **Blog**(导航) | 外链 Substack —— 首页 RSS 摘要链出全文 |
+| `**/misc**` | 心智侧面 —— 策展 **Watching**、**Things I Love**(工具与创作者)、**Remembrance** |
**Open Graph:** 各站内路由均有 `opengraph-image`(`[app/lib/og.tsx](app/lib/og.tsx)`)。**SEO:** `sitemap.ts`、`robots.ts`,首页嵌入 JSON-LD `Person`。
@@ -521,9 +516,9 @@ npm run lint && npm run typecheck && npm run test && npm run build
```text
kaichen.dev/
├── app/
-│ ├── layout.tsx # 根布局、metadata、主题、Nav、Analytics
-│ ├── page.tsx # 首页(身份、Spotify、天气、GitHub、Substack)
-│ ├── globals.css # 主题 token、.mag-card、暗色模式
+│ ├── layout.tsx # 根布局、metadata、主题、Nav、SiteFooter、Analytics
+│ ├── page.tsx # 首页(身份、Spotify、天气、GitHub)
+│ ├── globals.css # 主题 token、.mag-card、动画
│ ├── opengraph-image.tsx # 首页 OG
│ ├── about/ # 简介、教育、Focus、经历 + OG
│ ├── projects/ # 置顶仓库、贡献热力图 + OG
@@ -532,9 +527,9 @@ kaichen.dev/
│ │ ├── spotify/now-playing/
│ │ ├── github/contributions/
│ │ └── weather/
-│ ├── components/ # nav、listening-card、weather-card、theme 等
+│ ├── components/ # nav、site-footer、listening-card、weather-card、theme 等
│ ├── hooks/use-now-playing.ts
-│ ├── lib/ # og.tsx、substack.ts、github-pinned.ts
+│ ├── lib/ # og.tsx、github-pinned.ts
│ ├── sitemap.ts
│ └── robots.ts
├── lib/ # 服务端 helper + Vitest 测试
@@ -558,13 +553,13 @@ kaichen.dev/
| 路由 | 功能 |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `/` | 身份 + GWWC 标识;社交链接;**Listening**(Spotify,`[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`,约 10s 轮询,`cache: "no-store"`);**Location**(Open-Meteo,`GET /api/weather`,°C/°F、体感、湿度、降水概率,`[berkeley-time.tsx](app/components/berkeley-time.tsx)`);GitHub 置顶仓库(`[github-pinned.ts](app/lib/github-pinned.ts)`,GraphQL + 静态兜底);Substack RSS(`[substack.ts](app/lib/substack.ts)`) |
+| `/` | 身份 + GWWC 标识;社交链接;**Listening**(Spotify,`[use-now-playing.ts](app/hooks/use-now-playing.ts)` → `GET /api/spotify/now-playing`,约 10s 轮询,`cache: "no-store"`);**Location**(Open-Meteo,`GET /api/weather`,°C/°F、体感、湿度、降水概率,`[berkeley-time.tsx](app/components/berkeley-time.tsx)`);GitHub 置顶仓库(`[github-pinned.ts](app/lib/github-pinned.ts)`,GraphQL + 静态兜底) |
| `/about` | 性格、教育、Focus、经历、志愿 |
| `/projects` | 置顶项目卡片 + GitHub 贡献日历(`[/api/github/contributions](app/api/github/contributions/route.ts)`) |
-| `/misc` | News、Things I Love、Remembrance |
+| `/misc` | Watching、Things I Love、Remembrance |
-**站外导航:** **About · Projects · Notes · Blog · Misc** —— Notes 指向 Notion,Blog 指向 Substack(仓库内无 `/notes`、`/blog` 路由)。
+**导航:** **About · Projects · Misc** —— 每页页脚:`© 2026 Kai Thomas Chen. All rights reserved.`
#### API 路由
@@ -574,6 +569,7 @@ kaichen.dev/
| 方法与路径 | 行为 | 缓存 / 环境变量 |
| ------------------------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `GET /api/spotify/now-playing` | Spotify 当前播放 + 最近播放;播放中可选写入 Supabase `listening_`*;内存 `lastKnownTrack` 兜底 | `Cache-Control: public, s-maxage=10, stale-while-revalidate=5`;`SPOTIFY_*`;可选 `SUPABASE_*` |
+| `GET /api/spotify/last-month-top` | 上自然月 Top **5**(Supabase `listening_history`,按 `recorded_at` 筛选并分页) | `revalidate = 600`;可选 `SUPABASE_*` |
| `GET /api/github/contributions` | GraphQL 贡献日历(12 个月)→ `{ weeks, totalContributions }` | `revalidate = 300`;`GITHUB_TOKEN` |
| `GET /api/weather` | Open-Meteo 伯克利当前天气(`[weather-open-meteo.ts](lib/weather-open-meteo.ts)`) | `revalidate = 600`;无需 API key |
@@ -611,7 +607,6 @@ vercel env pull .env.vercel.check # 已 gitignore,勿提交
| **GitHub GraphQL** | 贡献日历 + 置顶仓库(star、archived) |
| **Open-Meteo** | 伯克利天气(无需 key) |
| **Supabase** | 可选听歌记录持久化 |
-| **Substack RSS** | 首页最新文章 |
#### 常见问题
@@ -626,7 +621,7 @@ vercel env pull .env.vercel.check # 已 gitignore,勿提交
#### 测试
-Vitest 单元测试位于 `lib/*.test.ts` —— Substack RSS、天气解析、Spotify helper。
+Vitest 单元测试位于 `lib/*.test.ts` —— 天气解析、Spotify helper。
```bash
npm run test
@@ -666,7 +661,6 @@ vercel --prod # 需先 vercel link
| Spotify OAuth | Spotify Developer Dashboard → `SPOTIFY_*`,见 `[lib/spotify-access-token.ts](lib/spotify-access-token.ts)` |
| GitHub 用户 / 置顶 | `GITHUB_LOGIN`、`[app/lib/github-pinned.ts](app/lib/github-pinned.ts)`、contributions 路由 |
| Supabase 表结构 | `[lib/listening-supabase.ts](lib/listening-supabase.ts)`、now-playing 路由 |
-| Substack 源 | `[app/lib/substack.ts](app/lib/substack.ts)` |
| 天气坐标 | `[app/api/weather/route.ts](app/api/weather/route.ts)` |
| Misc 列表 | `[app/misc/page.tsx](app/misc/page.tsx)` |
| 主题 / 字体 | `app/layout.tsx`、`app/globals.css`、`theme-provider.tsx` |
diff --git a/app/about/page.tsx b/app/about/page.tsx
index a7237c4..e835d67 100644
--- a/app/about/page.tsx
+++ b/app/about/page.tsx
@@ -3,7 +3,7 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About — Kai T. Chen",
description:
- "Education, current focus, experience, and volunteering — Maths at SUSTech, visiting UC Berkeley.",
+ "Education, current focus, experience, and volunteering — Maths at SUSTech, visiting at UC Berkeley.",
};
export default function About() {
@@ -22,7 +22,7 @@ export default function About() {
{/* Personality / side */}
-
+
I study Maths for my bachelor's degree and I'm always awed by the beauty of it (as the motivation to learn it through so much hard work)! I love the outline of analysis proofs and I'm especially obsessed with algebra structures.
I also learn some computer science and data science, and I find myself better at them than Maths lol. I'm drawn to the problem solving, ideas behind product design, and the empathy I feel when I'm building something for humans.
I really love tinkering stuff. I dream of building something that can help people / make people happy with a fantastic user experience! I also dream of building a tool that could change people's lives for the better.
diff --git a/app/api/spotify/last-month-top/route.ts b/app/api/spotify/last-month-top/route.ts
new file mode 100644
index 0000000..9fabc08
--- /dev/null
+++ b/app/api/spotify/last-month-top/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import {
+ createListeningSupabase,
+ getLastMonthTopFiveTracks,
+} from "@/lib/listening-supabase";
+
+export const revalidate = 600;
+
+export async function GET() {
+ const db = createListeningSupabase();
+ const tracks = await getLastMonthTopFiveTracks(db);
+
+ return NextResponse.json(
+ { tracks },
+ {
+ headers: {
+ "Cache-Control": "public, s-maxage=600, stale-while-revalidate=120",
+ },
+ }
+ );
+}
diff --git a/app/components/gwwc-badge.tsx b/app/components/gwwc-badge.tsx
new file mode 100644
index 0000000..980bdb5
--- /dev/null
+++ b/app/components/gwwc-badge.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import HoverTip from "./hover-tip";
+
+export default function GwwcBadge() {
+ return (
+
+ Giving What We Can — 10% Pledge Member.
+ I commit to donate at least 10% of my income to effective charities.
+
+ }
+ placement="top"
+ tipClassName="max-w-[280px] px-3.5 py-2.5 text-[13px] leading-relaxed text-left"
+ >
+
+ 🔸
+
+
+ );
+}
diff --git a/app/components/home-social-links.tsx b/app/components/home-social-links.tsx
new file mode 100644
index 0000000..00377ca
--- /dev/null
+++ b/app/components/home-social-links.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import type { ReactNode } from "react";
+import HoverTip from "./hover-tip";
+
+const SOCIAL_LINKS: Array<{
+ href: string;
+ label: string;
+ ariaLabel: string;
+ external?: boolean;
+ hoverClass: string;
+ icon: ReactNode;
+}> = [
+ {
+ href: "mailto:kaichen.dev.37@gmail.com",
+ label: "Email",
+ ariaLabel: "Email",
+ hoverClass:
+ "hover:text-[#EA4335] dark:hover:text-[#FCA5A5]",
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ href: "https://signal.me/#eu/CNyQcDgQ0KabVX2JyPske78Rwe0HQVHAstI2x1WyyfY_ivPGcChGG44wbzDzi23D",
+ label: "Signal",
+ ariaLabel: "Signal",
+ external: true,
+ hoverClass: "hover:text-[#3B45FD] dark:hover:text-[#9DBBF8]",
+ icon: (
+
+
+
+
+ ),
+ },
+ {
+ href: "https://github.com/kaiiiichen",
+ label: "GitHub",
+ ariaLabel: "GitHub",
+ external: true,
+ hoverClass: "hover:text-[#181717] dark:hover:text-white",
+ icon: (
+
+
+
+ ),
+ },
+ {
+ href: "https://linkedin.com/in/kaiiiichen",
+ label: "LinkedIn",
+ ariaLabel: "LinkedIn",
+ external: true,
+ hoverClass: "hover:text-[#0A66C2] dark:hover:text-[#70B5F9]",
+ icon: (
+
+
+
+ ),
+ },
+ {
+ href: "https://open.spotify.com/user/31xktcnl7hl34lu4windhu5uwji4?si=46cd7158fc2a4ab0",
+ label: "Spotify",
+ ariaLabel: "Spotify profile",
+ external: true,
+ hoverClass: "hover:text-[#1DB954] dark:hover:text-[#1ED760]",
+ icon: (
+
+
+
+ ),
+ },
+];
+
+export default function HomeSocialLinks() {
+ const linkClass =
+ "text-zinc-700 dark:text-zinc-300 opacity-50 transition-all duration-300 ease-out hover:opacity-100 [&_svg]:size-7";
+
+ return (
+
+ {SOCIAL_LINKS.map(({ href, label, ariaLabel, external, hoverClass, icon }) => (
+
+ {external ? (
+
+ {icon}
+
+ ) : (
+
+ {icon}
+
+ )}
+
+ ))}
+
+
+
+ ?
+
+
+
+ );
+}
diff --git a/app/components/hover-tip.tsx b/app/components/hover-tip.tsx
new file mode 100644
index 0000000..1e88587
--- /dev/null
+++ b/app/components/hover-tip.tsx
@@ -0,0 +1,202 @@
+"use client";
+
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type CSSProperties,
+ type ReactNode,
+} from "react";
+import { createPortal } from "react-dom";
+
+type HoverTipProps = {
+ tip: ReactNode;
+ children: ReactNode;
+ /** Tooltip placement relative to trigger */
+ placement?: "top" | "bottom";
+ /** Horizontal alignment of tooltip relative to trigger */
+ align?: "center" | "end";
+ /** Allow hovering / clicking links inside the tooltip */
+ interactive?: boolean;
+ /** Render tooltip in a body portal (escapes overflow:hidden ancestors) */
+ portal?: boolean;
+ className?: string;
+ tipClassName?: string;
+};
+
+const TIP_BASE_CLASS =
+ "rounded-sm border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-[#252019] px-2.5 py-1.5 text-center text-[11px] leading-snug text-zinc-600 dark:text-zinc-300 shadow-[3px_3px_0_0_var(--color-border-tertiary)] transition-all duration-150";
+
+function tipVisibilityClass(open: boolean) {
+ return open
+ ? "opacity-100 translate-y-0 pointer-events-auto"
+ : "opacity-0 translate-y-0.5 pointer-events-none";
+}
+
+function portalFixedStyle(
+ coords: DOMRect,
+ placement: "top" | "bottom",
+ align: "center" | "end"
+): CSSProperties {
+ const gap = 8;
+ const base: CSSProperties = {
+ position: "fixed",
+ zIndex: 99999,
+ fontFamily: "'Nunito'",
+ fontWeight: 400,
+ };
+
+ if (placement === "top" && align === "end") {
+ return {
+ ...base,
+ top: coords.top - gap,
+ right: window.innerWidth - coords.right,
+ transform: "translateY(-100%)",
+ };
+ }
+ if (placement === "top" && align === "center") {
+ return {
+ ...base,
+ top: coords.top - gap,
+ left: coords.left + coords.width / 2,
+ transform: "translate(-50%, -100%)",
+ };
+ }
+ if (placement === "bottom" && align === "end") {
+ return {
+ ...base,
+ top: coords.bottom + gap,
+ right: window.innerWidth - coords.right,
+ };
+ }
+ return {
+ ...base,
+ top: coords.bottom + gap,
+ left: coords.left + coords.width / 2,
+ transform: "translateX(-50%)",
+ };
+}
+
+export default function HoverTip({
+ tip,
+ children,
+ placement = "top",
+ align = "center",
+ interactive = false,
+ portal = false,
+ className = "",
+ tipClassName = "",
+}: HoverTipProps) {
+ const triggerRef = useRef
(null);
+ const hideTimerRef = useRef | null>(null);
+ const [mounted, setMounted] = useState(false);
+ const [open, setOpen] = useState(false);
+ const [coords, setCoords] = useState(null);
+
+ useEffect(() => {
+ queueMicrotask(() => setMounted(true));
+ }, []);
+
+ const syncCoords = useCallback(() => {
+ const el = triggerRef.current;
+ if (!el) return;
+ setCoords(el.getBoundingClientRect());
+ }, []);
+
+ const show = useCallback(() => {
+ if (hideTimerRef.current) {
+ clearTimeout(hideTimerRef.current);
+ hideTimerRef.current = null;
+ }
+ syncCoords();
+ setOpen(true);
+ }, [syncCoords]);
+
+ const hide = useCallback(() => {
+ setOpen(false);
+ }, []);
+
+ const scheduleHide = useCallback(() => {
+ if (!interactive) {
+ hide();
+ return;
+ }
+ hideTimerRef.current = setTimeout(hide, 120);
+ }, [hide, interactive]);
+
+ useEffect(() => {
+ if (!open || !portal) return;
+ const onUpdate = () => syncCoords();
+ window.addEventListener("scroll", onUpdate, true);
+ window.addEventListener("resize", onUpdate);
+ return () => {
+ window.removeEventListener("scroll", onUpdate, true);
+ window.removeEventListener("resize", onUpdate);
+ };
+ }, [open, portal, syncCoords]);
+
+ useEffect(
+ () => () => {
+ if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
+ },
+ []
+ );
+
+ if (portal && mounted) {
+ const fixedStyle = coords
+ ? portalFixedStyle(coords, placement, align)
+ : { position: "fixed" as const, visibility: "hidden" as const };
+
+ return (
+ <>
+
+ {children}
+
+ {createPortal(
+
+ {tip}
+ ,
+ document.body
+ )}
+ >
+ );
+ }
+
+ const position =
+ placement === "top"
+ ? align === "end"
+ ? "bottom-full right-0 mb-2"
+ : "bottom-full left-1/2 -translate-x-1/2 mb-2"
+ : align === "end"
+ ? "top-full right-0 mt-2"
+ : "top-full left-1/2 -translate-x-1/2 mt-2";
+
+ return (
+
+ {children}
+
+ {tip}
+
+
+ );
+}
diff --git a/app/components/listening-card.tsx b/app/components/listening-card.tsx
index 2f43b1c..3847bbf 100644
--- a/app/components/listening-card.tsx
+++ b/app/components/listening-card.tsx
@@ -1,7 +1,9 @@
"use client";
-import Image from "next/image";
+
import { useNowPlaying } from "@/app/hooks/use-now-playing";
import type { CSSProperties } from "react";
+import ListeningLastMonthTop from "./listening-last-month-top";
+import ListeningTrackRow from "./listening-track-row";
// Spotify's audio-features API was retired (403 for this app), so the live dot
// uses fixed timings instead of per-track BPM/intensity.
@@ -41,82 +43,20 @@ export default function ListeningCard() {
const stateText = live ? "now playing" : hasTrack ? "last played" : "idle";
- // Ring around the album art only appears while hovering the track row, in
- // both "now playing" and "last played" states.
- const hoverRing =
- "ring-0 group-hover/track:ring-[1.5px] " +
- "group-hover/track:ring-[#C4894F]/45 group-hover/track:ring-offset-[2px] " +
- "group-hover/track:ring-offset-white " +
- "dark:group-hover/track:ring-[#D9A870]/40 dark:group-hover/track:ring-offset-[#252019]";
-
return (
-
+
@@ -149,9 +89,11 @@ export default function ListeningCard() {
>
{stateText}
+
+
+
-
);
}
diff --git a/app/components/listening-last-month-top.tsx b/app/components/listening-last-month-top.tsx
new file mode 100644
index 0000000..bd98947
--- /dev/null
+++ b/app/components/listening-last-month-top.tsx
@@ -0,0 +1,73 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { ListeningHighlightTrack } from "@/lib/listening-monthly-top";
+import HoverTip from "./hover-tip";
+import ListeningTrackRow from "./listening-track-row";
+
+export default function ListeningLastMonthTop() {
+ const [tracks, setTracks] = useState
(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ fetch("/api/spotify/last-month-top")
+ .then((r) => r.json())
+ .then((body: { tracks?: ListeningHighlightTrack[] }) => {
+ if (!cancelled) setTracks(body.tracks ?? []);
+ })
+ .catch(() => {
+ if (!cancelled) setTracks([]);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (tracks === null) return null;
+
+ return (
+
+
+ last month · top 5
+
+ {tracks.length === 0 ? (
+
+ No plays recorded last month.
+
+ ) : (
+ tracks.map((track) => (
+
+ ))
+ )}
+
+ }
+ >
+
+ last month's tops
+
+
+ );
+}
diff --git a/app/components/listening-track-row.tsx b/app/components/listening-track-row.tsx
new file mode 100644
index 0000000..d90b0b2
--- /dev/null
+++ b/app/components/listening-track-row.tsx
@@ -0,0 +1,95 @@
+import Image from "next/image";
+
+const HOVER_RING =
+ "ring-0 group-hover/track:ring-[1.5px] " +
+ "group-hover/track:ring-[#C4894F]/45 group-hover/track:ring-offset-[2px] " +
+ "group-hover/track:ring-offset-white " +
+ "dark:group-hover/track:ring-[#D9A870]/40 dark:group-hover/track:ring-offset-[#252019]";
+
+type ListeningTrackRowProps = {
+ title: string;
+ artist: string;
+ albumArt: string;
+ songUrl: string;
+ live?: boolean;
+ compact?: boolean;
+ className?: string;
+};
+
+export default function ListeningTrackRow({
+ title,
+ artist,
+ albumArt,
+ songUrl,
+ live = false,
+ compact = false,
+ className = "",
+}: ListeningTrackRowProps) {
+ const artSize = compact ? 32 : 40;
+ const artClass = compact ? "size-8" : "size-10";
+
+ return (
+
+
+ ↗
+
+
+
+ {albumArt ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {title}
+
+
+ {artist}
+
+
+
+ );
+}
diff --git a/app/components/nav.tsx b/app/components/nav.tsx
index 9abf192..3e9fe17 100644
--- a/app/components/nav.tsx
+++ b/app/components/nav.tsx
@@ -1,50 +1,35 @@
"use client";
import Link from "next/link";
+import { usePathname } from "next/navigation";
import ThemeToggle from "./theme-toggle";
-import { useState, useEffect, useRef, useCallback } from "react";
-import { createPortal } from "react-dom";
-
-const WAVE_SVG = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='4'%3E%3Cpath d='M0 3 Q5 0 10 3 Q15 6 20 3' stroke='%23C4894F' stroke-width='1.5' fill='none'/%3E%3C/svg%3E")`;
+import { useState, useEffect, useRef } from "react";
const NAV_LINKS = [
- { href: "/about", label: "About", external: false },
- { href: "/projects", label: "Projects", external: false },
- {
- href: "https://kaichen-dev.notion.site/kaichen-dev-notion-site-357a3294e48780f3b570f2f48f1081a4?pvs=74",
- label: "Notes",
- external: true,
- },
- { href: "https://kaiiiichen.substack.com/", label: "Blog", external: true },
- { href: "/misc", label: "Misc", external: false },
+ { href: "/about", label: "About" },
+ { href: "/projects", label: "Projects" },
+ { href: "/misc", label: "Misc" },
];
+function isNavActive(pathname: string, href: string) {
+ if (href === "/") return pathname === "/";
+ return pathname === href || pathname.startsWith(`${href}/`);
+}
+
+/** Active nav — accent text + 2px underline; hover — text highlight only */
+function navLinkClassName(active: boolean) {
+ const base =
+ "text-sm transition-colors duration-150 pb-0.5 border-b-2";
+ if (active) {
+ return `${base} text-[#C4894F] dark:text-[#D9A870] border-[#C4894F] dark:border-[#D9A870]`;
+ }
+ return `${base} text-zinc-500 dark:text-zinc-400 border-transparent hover:text-[#C4894F] dark:hover:text-[#D9A870]`;
+}
+
export default function Nav() {
- const [mounted, setMounted] = useState(false);
+ const pathname = usePathname();
const [isOpen, setIsOpen] = useState(false);
const navRef = useRef(null);
- const [waveRect, setWaveRect] = useState<{ left: number; width: number; top: number } | null>(null);
- const waveLeaveFrame = useRef(0);
-
- useEffect(() => {
- queueMicrotask(() => setMounted(true));
- }, []);
-
- const onWaveEnter = useCallback((e: React.MouseEvent) => {
- const el = e.currentTarget;
- const r = el.getBoundingClientRect();
- cancelAnimationFrame(waveLeaveFrame.current);
- setWaveRect({ left: r.left, width: r.width, top: r.bottom + 2 });
- }, []);
-
- const onWaveLeave = useCallback(() => {
- waveLeaveFrame.current = requestAnimationFrame(() => setWaveRect(null));
- }, []);
-
- const waveProps = {
- onMouseEnter: onWaveEnter,
- onMouseLeave: onWaveLeave,
- };
useEffect(() => {
if (!isOpen) return;
@@ -58,45 +43,29 @@ export default function Nav() {
}, [isOpen]);
return (
- <>
- Kai Thomas Chen
+ Kai T. Chen
{/* Desktop nav */}
- {NAV_LINKS.map(({ href, label, external }) =>
- external ? (
-
- {label}
-
- ) : (
-
- {label}
-
- )
- )}
+ {NAV_LINKS.map(({ href, label }) => (
+
+ {label}
+
+ ))}
@@ -129,8 +98,8 @@ export default function Nav() {
}`}
>
- {NAV_LINKS.map(({ href, label, external }, i) => {
- const linkClass = `text-base text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100 transition-all duration-200 py-2 ${
+ {NAV_LINKS.map(({ href, label }, i) => {
+ const linkClass = `${navLinkClassName(isNavActive(pathname, href))} transition-all duration-200 py-2 ${
isOpen ? "opacity-100 translate-y-0" : "opacity-0 -translate-y-1"
}`;
const linkStyle = {
@@ -138,19 +107,7 @@ export default function Nav() {
fontWeight: 400,
transitionDelay: isOpen ? `${60 + i * 40}ms` : "0ms",
};
- return external ? (
-
setIsOpen(false)}
- style={linkStyle}
- className={linkClass}
- >
- {label}
-
- ) : (
+ return (
- {mounted
- ? createPortal(
-
,
- document.body
- )
- : null}
- >
);
}
diff --git a/app/components/pinned-project-link.tsx b/app/components/pinned-project-link.tsx
new file mode 100644
index 0000000..a2481f4
--- /dev/null
+++ b/app/components/pinned-project-link.tsx
@@ -0,0 +1,116 @@
+import ProjectStars from "./project-stars";
+import type { PinnedProject } from "@/app/lib/github-pinned";
+
+type PinnedProjectLinkProps = PinnedProject & {
+ variant?: "list" | "card";
+};
+
+export default function PinnedProjectLink({
+ name,
+ desc,
+ href,
+ stack,
+ stars,
+ archived,
+ variant = "list",
+}: PinnedProjectLinkProps) {
+ const nameClass =
+ "text-zinc-800 dark:text-zinc-200 group-hover:text-[#C4894F] dark:group-hover:text-[#D9A870] transition-colors duration-150";
+ const nameStyle = {
+ fontFamily: "'Nunito'",
+ fontWeight: 600,
+ fontSize: 20,
+ fontStyle: "italic" as const,
+ };
+
+ if (variant === "card") {
+ return (
+
+
+
+ {desc ? (
+
+ {desc}
+
+ ) : null}
+
+
+
+ {stack.map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ GitHub ↗
+
+
+
+ );
+ }
+
+ return (
+
+
+ {desc ? (
+
+ {desc}
+
+ ) : null}
+ {stack.length > 0 ? (
+
+ {stack.map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : null}
+
+ );
+}
diff --git a/app/components/project-stars.tsx b/app/components/project-stars.tsx
index 2e0dc25..2bc5782 100644
--- a/app/components/project-stars.tsx
+++ b/app/components/project-stars.tsx
@@ -15,9 +15,6 @@ export default function ProjectStars({ stars, archived }: ProjectStarsProps) {
return (
<>
-
- public
-
{archived && (
archived
diff --git a/app/components/site-footer.tsx b/app/components/site-footer.tsx
new file mode 100644
index 0000000..5578d10
--- /dev/null
+++ b/app/components/site-footer.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+
+export default function SiteFooter() {
+ return (
+
+ );
+}
diff --git a/app/components/theme-provider.tsx b/app/components/theme-provider.tsx
index e0867e1..5723f7c 100644
--- a/app/components/theme-provider.tsx
+++ b/app/components/theme-provider.tsx
@@ -9,18 +9,11 @@ import {
useState,
} from "react";
-type Theme = "light" | "dark" | "system";
+type Theme = "light" | "dark";
const STORAGE_KEY = "theme";
-function getSystemTheme(): "light" | "dark" {
- if (typeof window === "undefined") return "light";
- return window.matchMedia("(prefers-color-scheme: dark)").matches
- ? "dark"
- : "light";
-}
-
-function applyResolved(resolved: "light" | "dark") {
+function applyResolved(resolved: Theme) {
const root = document.documentElement;
root.classList.toggle("dark", resolved === "dark");
root.style.colorScheme = resolved;
@@ -29,22 +22,20 @@ function applyResolved(resolved: "light" | "dark") {
type ThemeContextValue = {
theme: Theme;
setTheme: (t: Theme) => void;
- resolvedTheme: "light" | "dark";
+ resolvedTheme: Theme;
};
const ThemeContext = createContext(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
- const [theme, setThemeState] = useState("system");
+ const [theme, setThemeState] = useState("light");
useEffect(() => {
queueMicrotask(() => {
try {
- const s = localStorage.getItem(STORAGE_KEY) as Theme | null;
- if (s === "light" || s === "dark" || s === "system") {
- setThemeState(s);
- } else {
- setThemeState("light");
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored === "light" || stored === "dark") {
+ setThemeState(stored);
}
} catch {
/* ignore */
@@ -52,19 +43,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
});
}, []);
- const resolvedTheme: "light" | "dark" =
- theme === "system" ? getSystemTheme() : theme;
-
- useEffect(() => {
- applyResolved(resolvedTheme);
- }, [resolvedTheme]);
-
useEffect(() => {
- if (theme !== "system") return;
- const mq = window.matchMedia("(prefers-color-scheme: dark)");
- const onChange = () => applyResolved(getSystemTheme());
- mq.addEventListener("change", onChange);
- return () => mq.removeEventListener("change", onChange);
+ applyResolved(theme);
}, [theme]);
const setTheme = useCallback((t: Theme) => {
@@ -74,12 +54,12 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
} catch {
/* ignore */
}
- applyResolved(t === "system" ? getSystemTheme() : t);
+ applyResolved(t);
}, []);
const value = useMemo(
- () => ({ theme, setTheme, resolvedTheme }),
- [theme, setTheme, resolvedTheme]
+ () => ({ theme, setTheme, resolvedTheme: theme }),
+ [theme, setTheme]
);
return (
diff --git a/app/components/weather-card.tsx b/app/components/weather-card.tsx
index 3b2baec..5c1f076 100644
--- a/app/components/weather-card.tsx
+++ b/app/components/weather-card.tsx
@@ -8,7 +8,6 @@ type WeatherData = {
feelsLike: number;
humidity: number;
weatherCode: number;
- emoji: string;
condition: string;
rainChance: number;
};
@@ -38,7 +37,7 @@ export default function WeatherCard() {
style={{ fontFamily: "'Nunito'", fontWeight: 400, fontSize: 14, letterSpacing: "0.02em" }}
className="text-zinc-600 dark:text-zinc-400 leading-[1.1]"
>
- Berkeley · CA
+ Based in Berkeley · CA
{w
- ? `feels ${isCelsius ? `${w.feelsLike}°C` : `${Math.round(w.feelsLike * 9 / 5 + 32)}°F`} · humidity ${w.humidity}%`
- : "Feels — · Humidity —"}
+ ? `feels ${isCelsius ? `${w.feelsLike}°C` : `${Math.round(w.feelsLike * 9 / 5 + 32)}°F`} · humidity ${w.humidity}% · rain ${w.rainChance}%`
+ : "Feels — · Humidity — · Rain —"}
diff --git a/app/globals.css b/app/globals.css
index 50cd875..179ce77 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -6,20 +6,19 @@
:root {
--background: #F7F3EE;
--foreground: #171717;
- --dot-idle: #a1a1aa;
--accent: #C4894F;
--contribution-empty: #e4e4e7;
--contribution-l1: #c6e48b;
--contribution-l2: #7bc96f;
--contribution-l3: #239a3b;
--contribution-l4: #196127;
- --font-chinese: 'Chiron GoRound TC', 'Ma Shan Zheng', serif;
/* Match nav + card UI English (see app/components/nav.tsx) */
--font-ui-en: 'Nunito', ui-sans-serif, system-ui, sans-serif;
/* Border hierarchy for mag-card */
--color-border-primary: #a1a1aa; /* zinc-400 — bottom accent */
--color-border-secondary: #d4d4d8; /* zinc-300 — sides */
--color-border-tertiary: #e4e4e7; /* zinc-200 — shadow */
+ --mag-card-radius: 6px;
}
.dark {
@@ -41,9 +40,6 @@
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
- --font-serif: var(--font-lora);
- --font-instrument-serif: var(--font-instrument-serif);
- --font-dm-sans: var(--font-dm-sans);
--font-jetbrains-mono: 'JetBrains Mono', monospace;
}
@@ -69,7 +65,7 @@ body {
border-bottom: 3px solid var(--color-border-primary);
border-right: 2px solid var(--color-border-secondary);
box-shadow: 5px 5px 0px 0px var(--color-border-tertiary);
- border-radius: 2px;
+ border-radius: var(--mag-card-radius);
padding: 1.5rem;
overflow: hidden;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
@@ -132,17 +128,6 @@ body {
.slide-enter { animation: slide-in-right 250ms ease forwards; }
/* ── Listening widget (home) ──────────────────────────────── */
-@keyframes listening-eq-wave {
- 0%,
- 100% {
- transform: scaleY(0.28);
- opacity: 0.5;
- }
- 50% {
- transform: scaleY(1);
- opacity: 1;
- }
-}
@keyframes listening-live-pulse {
0%,
100% {
@@ -152,12 +137,13 @@ body {
box-shadow: 0 0 0 10px rgb(29 185 84 / 0);
}
}
-@keyframes listening-sheen-shift {
- 0% {
- background-position: 0% 50%;
- }
+@keyframes listening-live-pulse-dark {
+ 0%,
100% {
- background-position: 100% 50%;
+ box-shadow: 0 0 0 0 rgb(29 185 84 / 0.4);
+ }
+ 55% {
+ box-shadow: 0 0 0 10px rgb(29 185 84 / 0);
}
}
@keyframes listening-soft-breathe {
@@ -170,521 +156,12 @@ body {
}
}
-.listening-eq-bar-active {
- width: 3px;
- height: 12px;
- border-radius: 9999px;
- transform-origin: center bottom;
- transform: scaleY(0.35);
- animation: listening-eq-wave 0.82s ease-in-out infinite;
- will-change: transform;
-}
-
-.listening-eq-bar-idle {
- width: 3px;
- border-radius: 9999px;
- transform-origin: center bottom;
- opacity: 0.38;
-}
-
-.listening-eq-bar-idle:nth-child(1) {
- height: 5px;
-}
-.listening-eq-bar-idle:nth-child(2) {
- height: 9px;
-}
-.listening-eq-bar-idle:nth-child(3) {
- height: 7px;
-}
-.listening-eq-bar-idle:nth-child(4) {
- height: 11px;
-}
-
-.listening-wave-shell {
- position: relative;
- width: 24px;
- height: 12px;
- border-radius: 9999px;
- overflow: hidden;
- background: rgb(212 212 216 / 0.45);
- border: 1px solid rgb(212 212 216 / 0.6);
-}
-.dark .listening-wave-shell {
- background: rgb(63 63 70 / 0.55);
- border-color: rgb(82 82 91 / 0.65);
-}
-.listening-wave-orb {
- position: absolute;
- top: 2px;
- left: 2px;
- width: 6px;
- height: 6px;
- border-radius: 9999px;
- animation: listening-liquid-flow var(--fx-speed, 820ms) ease-in-out infinite;
- will-change: transform, opacity;
-}
-@keyframes listening-liquid-flow {
- 0% {
- transform: translateX(0px) scale(0.74);
- opacity: 0.52;
- }
- 45% {
- transform: translateX(calc(var(--wave-travel, 16px) * 0.5)) scale(var(--wave-scale-peak, 1.08));
- opacity: 0.98;
- }
- 100% {
- transform: translateX(var(--wave-travel, 16px)) scale(0.72);
- opacity: 0.5;
- }
-}
-
-.listening-fx-bars {
- display: flex;
- align-items: end;
- gap: 2px;
- width: 34px;
- height: 14px;
-}
-.listening-fx-bar {
- width: 4px;
- border-radius: 9999px;
- animation: listening-prism-bars var(--fx-speed, 820ms) ease-in-out infinite;
- transform-origin: center bottom;
-}
-@keyframes listening-prism-bars {
- 0%,
- 100% { height: 4px; opacity: 0.45; }
- 50% { height: calc(7px + var(--fx-intensity, 0.5) * 7px); opacity: 1; }
-}
-
-.listening-fx-orbit {
- position: relative;
- width: 16px;
- height: 16px;
- border-radius: 9999px;
- border: 1px solid rgb(161 161 170 / 0.45);
-}
-.dark .listening-fx-orbit {
- border-color: rgb(82 82 91 / 0.65);
-}
-.listening-fx-comet {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 4px;
- height: 4px;
- border-radius: 9999px;
- transform-origin: -6px 0;
- animation: listening-orbit-spin var(--fx-pulse, 2200ms) linear infinite;
-}
-@keyframes listening-orbit-spin {
- from { transform: rotate(0deg) translateX(6px); opacity: 0.45; }
- to { transform: rotate(360deg) translateX(6px); opacity: 1; }
-}
-
-.listening-fx-radar {
- position: relative;
- width: 20px;
- height: 20px;
- border-radius: 9999px;
-}
-.listening-fx-radar::before,
-.listening-fx-radar::after {
- content: "";
- position: absolute;
- inset: 0;
- border-radius: 9999px;
- border: 1px solid rgb(161 161 170 / 0.45);
- animation: listening-radar var(--fx-pulse, 2200ms) ease-out infinite;
-}
-.listening-fx-radar::after {
- animation-delay: calc(var(--fx-pulse, 2200ms) * -0.5);
-}
-.dark .listening-fx-radar::before,
-.dark .listening-fx-radar::after {
- border-color: rgb(82 82 91 / 0.65);
-}
-.listening-fx-radar-dot {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 4px;
- height: 4px;
- border-radius: 9999px;
- transform: translate(-50%, -50%) scale(calc(0.85 + var(--fx-intensity, 0.5) * 0.45));
-}
-@keyframes listening-radar {
- from { transform: scale(0.35); opacity: 0.85; }
- to { transform: scale(1); opacity: 0; }
-}
-
-.listening-fx-ribbon {
- position: relative;
- width: 34px;
- height: 12px;
- overflow: hidden;
-}
-.listening-fx-ribbon-line {
- position: absolute;
- left: -24px;
- top: 4px;
- width: 58px;
- height: 3px;
- border-radius: 9999px;
- filter: saturate(calc(0.8 + var(--fx-intensity, 0.5) * 0.4));
- animation: listening-ribbon-flow var(--fx-speed, 820ms) linear infinite;
-}
-@keyframes listening-ribbon-flow {
- from { transform: translateX(0px) skewX(-16deg); opacity: 0.5; }
- 50% { transform: translateX(14px) skewX(0deg); opacity: 1; }
- to { transform: translateX(28px) skewX(16deg); opacity: 0.5; }
-}
-
-.listening-fx-lattice {
- position: relative;
- width: 34px;
- height: 12px;
-}
-.listening-fx-spark {
- position: absolute;
- width: 3px;
- height: 3px;
- border-radius: 9999px;
- animation: listening-spark-lattice var(--fx-speed, 820ms) ease-in-out infinite;
-}
-.listening-fx-spark:nth-child(1) { left: 2px; top: 2px; }
-.listening-fx-spark:nth-child(2) { left: 8px; top: 8px; }
-.listening-fx-spark:nth-child(3) { left: 14px; top: 4px; }
-.listening-fx-spark:nth-child(4) { left: 20px; top: 1px; }
-.listening-fx-spark:nth-child(5) { left: 26px; top: 7px; }
-.listening-fx-spark:nth-child(6) { left: 31px; top: 3px; }
-@keyframes listening-spark-lattice {
- 0%, 100% { transform: scale(0.55); opacity: 0.35; }
- 45% { transform: scale(calc(0.8 + var(--fx-intensity, 0.5) * 0.8)); opacity: 1; }
-}
-
-.listening-fx-strobe {
- position: relative;
- width: 18px;
- height: 18px;
- border-radius: 9999px;
- border: 1px dashed rgb(161 161 170 / 0.55);
- animation: listening-strobe-spin calc(var(--fx-pulse, 2200ms) * 1.2) linear infinite;
-}
-.dark .listening-fx-strobe {
- border-color: rgb(82 82 91 / 0.65);
-}
-.listening-fx-strobe-core {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 5px;
- height: 5px;
- border-radius: 9999px;
- transform: translate(-50%, -50%) scale(calc(0.8 + var(--fx-phase, 0) * 0.5));
- opacity: calc(0.4 + var(--fx-phase, 0) * 0.6);
-}
-@keyframes listening-strobe-spin {
- from { transform: rotate(0deg); }
- to { transform: rotate(360deg); }
-}
-
-/* New FX gallery (v2) */
-.listening-fx-crest {
- display: flex;
- align-items: end;
- gap: 2px;
- width: 38px;
- height: 14px;
-}
-.listening-fx-crest-peak {
- width: 3px;
- height: 4px;
- border-radius: 9999px;
- transform-origin: center bottom;
- animation: listening-crest-random var(--fx-speed, 820ms) ease-in-out infinite;
-}
-.listening-fx-crest-peak:nth-child(2n) { animation-duration: calc(var(--fx-speed, 820ms) * 1.2); }
-.listening-fx-crest-peak:nth-child(3n) { animation-duration: calc(var(--fx-speed, 820ms) * 0.78); }
-@keyframes listening-crest-random {
- 0%, 100% { transform: scaleY(0.34); opacity: 0.38; }
- 37% { transform: scaleY(calc(0.55 + var(--fx-intensity, 0.5) * 0.95)); opacity: 1; }
- 64% { transform: scaleY(calc(0.25 + var(--fx-phase, 0) * 1.05)); opacity: 0.7; }
-}
-
-.listening-fx-shards {
- position: relative;
- width: 84px;
- height: 20px;
- padding: 1px 0 2px;
- overflow: hidden;
-}
-.listening-fx-shards::before {
- content: "";
- position: absolute;
- inset: 1px -16px;
- background: linear-gradient(
- 100deg,
- transparent 0%,
- rgb(255 255 255 / 0.18) 45%,
- rgb(255 255 255 / 0.42) 50%,
- rgb(255 255 255 / 0.18) 55%,
- transparent 100%
- );
- transform: translateX(-120%);
- opacity: 0;
- pointer-events: none;
-}
-.dark .listening-fx-shards::before {
- background: linear-gradient(
- 100deg,
- transparent 0%,
- rgb(217 168 112 / 0.08) 45%,
- rgb(217 168 112 / 0.2) 50%,
- rgb(217 168 112 / 0.08) 55%,
- transparent 100%
- );
-}
-.listening-fx-shards-live {
- animation: listening-shards-breath calc(var(--fx-pulse, 2200ms) * 0.95) ease-in-out infinite;
-}
-.listening-fx-shards-live::before {
- opacity: 1;
- animation: listening-shards-sweep calc(var(--fx-pulse, 2200ms) * 1.35) cubic-bezier(.35,.05,.25,1) infinite;
-}
-.listening-fx-shard {
- position: absolute;
- left: var(--shard-x, 0px);
- bottom: 1px;
- width: 5px;
- height: var(--shard-height, 10px);
- border-radius: 2px;
- transform-origin: center bottom;
- opacity: 0.5;
- filter: saturate(calc(0.9 + var(--fx-intensity, 0.5) * 0.6));
- box-shadow:
- 0 0 0 1px rgb(255 255 255 / 0.06) inset,
- 0 0 6px rgb(29 185 84 / 0.22);
- animation:
- listening-shard-flick var(--fx-pulse, 2200ms) cubic-bezier(.17,.84,.44,1) infinite,
- listening-shard-drift calc(var(--fx-speed, 820ms) * 2.2) ease-in-out infinite;
-}
-@keyframes listening-shard-flick {
- 0%, 100% {
- transform: translateY(2px) scaleY(0.62) rotate(calc(var(--shard-tilt, 8deg) * -0.45));
- opacity: 0.35;
- }
- 35% {
- transform: translateY(0px) scaleY(calc(0.92 + var(--fx-intensity, 0.5) * 0.55)) rotate(calc(var(--shard-tilt, 8deg) * 0.35));
- opacity: 0.95;
- }
- 62% {
- transform: translateY(-1px) scaleY(calc(0.85 + var(--fx-phase, 0) * 0.5)) rotate(var(--shard-tilt, 8deg));
- opacity: 0.75;
- }
-}
-@keyframes listening-shard-drift {
- 0%, 100% { margin-bottom: 0px; }
- 50% { margin-bottom: calc(1px + var(--fx-intensity, 0.5) * 2px); }
-}
-@keyframes listening-shards-sweep {
- 0%, 28% {
- transform: translateX(-120%);
- opacity: 0;
- }
- 44% {
- opacity: calc(0.22 + var(--fx-intensity, 0.5) * 0.35);
- }
- 62% {
- transform: translateX(120%);
- opacity: 0;
- }
- 100% {
- transform: translateX(120%);
- opacity: 0;
- }
-}
-@keyframes listening-shards-breath {
- 0%, 100% {
- transform: translateY(0px) scaleY(0.98);
- }
- 18% {
- transform: translateY(-0.5px) scaleY(calc(1 + var(--fx-intensity, 0.5) * 0.06));
- }
- 52% {
- transform: translateY(0px) scaleY(0.995);
- }
-}
-
-.listening-fx-pendulum {
- display: flex;
- align-items: center;
- gap: 4px;
- width: 34px;
- height: 14px;
-}
-.listening-fx-pendulum-dot {
- width: 5px;
- height: 5px;
- border-radius: 9999px;
- transform-origin: center -4px;
- animation: listening-pendulum-sway var(--fx-pulse, 2200ms) ease-in-out infinite;
-}
-@keyframes listening-pendulum-sway {
- 0%, 100% { transform: rotate(-20deg) translateY(1px); opacity: 0.5; }
- 50% { transform: rotate(20deg) translateY(-1px); opacity: 1; }
-}
-
-.listening-fx-drizzle {
- position: relative;
- width: 34px;
- height: 14px;
- overflow: hidden;
-}
-.listening-fx-drop {
- position: absolute;
- top: -1px;
- width: 3px;
- height: 3px;
- border-radius: 1px;
- animation: listening-drop-fall var(--fx-speed, 820ms) linear infinite;
-}
-.listening-fx-drop:nth-child(1){ left: 3px; }
-.listening-fx-drop:nth-child(2){ left: 10px; }
-.listening-fx-drop:nth-child(3){ left: 17px; }
-.listening-fx-drop:nth-child(4){ left: 24px; }
-.listening-fx-drop:nth-child(5){ left: 30px; }
-@keyframes listening-drop-fall {
- from { transform: translateY(-2px) scale(0.85); opacity: 0.2; }
- 30% { opacity: 0.95; }
- to { transform: translateY(13px) scale(calc(0.65 + var(--fx-intensity, 0.5) * 0.4)); opacity: 0.15; }
-}
-
-.listening-fx-moire {
- position: relative;
- width: 34px;
- height: 12px;
- overflow: hidden;
-}
-.listening-fx-moire-line {
- position: absolute;
- inset: 4px -8px;
- height: 2px;
- border-radius: 9999px;
- opacity: 0.65;
- animation: listening-moire-shift var(--fx-speed, 820ms) ease-in-out infinite;
-}
-.listening-fx-moire-line:last-child {
- animation-delay: calc(var(--fx-speed, 820ms) * -0.5);
- opacity: 0.45;
-}
-@keyframes listening-moire-shift {
- 0%,100% { transform: translateX(-4px) skewX(-22deg); }
- 50% { transform: translateX(10px) skewX(20deg); }
-}
-
-.listening-fx-braid {
- position: relative;
- width: 34px;
- height: 12px;
-}
-.listening-fx-braid::before {
- content: "";
- position: absolute;
- left: 1px;
- right: 1px;
- top: 5px;
- height: 1px;
- background: rgb(161 161 170 / 0.35);
-}
-.dark .listening-fx-braid::before {
- background: rgb(82 82 91 / 0.5);
-}
-.listening-fx-braid-node {
- position: absolute;
- top: 4px;
- width: 4px;
- height: 4px;
- border-radius: 9999px;
- animation: listening-braid-phase calc(var(--fx-pulse, 2200ms) * 0.9) ease-in-out infinite;
-}
-.listening-fx-braid-node:nth-child(1) { left: 4px; }
-.listening-fx-braid-node:nth-child(2) { left: 15px; }
-.listening-fx-braid-node:nth-child(3) { left: 26px; }
-@keyframes listening-braid-phase {
- 0%,100% { transform: translateY(0px) scale(0.7); opacity: 0.4; }
- 50% { transform: translateY(calc(-3px - var(--fx-phase, 0) * 2px)) scale(1.05); opacity: 1; }
-}
-
-.listening-fx-glyph {
- position: relative;
- width: 20px;
- height: 20px;
-}
-.listening-fx-glyph::before,
-.listening-fx-glyph::after {
- content: "";
- position: absolute;
- inset: 1px;
- border-radius: 9999px;
- border: 1px solid rgb(161 161 170 / 0.45);
- animation: listening-glyph-ripple var(--fx-pulse, 2200ms) ease-out infinite;
-}
-.listening-fx-glyph::after {
- animation-delay: calc(var(--fx-pulse, 2200ms) * -0.45);
-}
-.dark .listening-fx-glyph::before,
-.dark .listening-fx-glyph::after {
- border-color: rgb(82 82 91 / 0.65);
-}
-.listening-fx-glyph-core {
- position: absolute;
- top: 50%;
- left: 50%;
- width: 5px;
- height: 5px;
- border-radius: 9999px;
- transform: translate(-50%, -50%);
-}
-@keyframes listening-glyph-ripple {
- from { transform: scale(0.45); opacity: 0.8; }
- to { transform: scale(calc(0.85 + var(--fx-intensity, 0.5) * 0.35)); opacity: 0; }
-}
-
.listening-live-dot {
animation: listening-live-pulse 2.2s ease-out infinite;
}
.dark .listening-live-dot {
animation-name: listening-live-pulse-dark;
}
-@keyframes listening-live-pulse-dark {
- 0%,
- 100% {
- box-shadow: 0 0 0 0 rgb(29 185 84 / 0.4);
- }
- 55% {
- box-shadow: 0 0 0 10px rgb(29 185 84 / 0);
- }
-}
-
-.listening-card-glow {
- animation: listening-sheen-shift 14s linear infinite alternate;
- background-image: linear-gradient(
- 120deg,
- rgb(249 246 243 / 0.55) 0%,
- rgb(255 255 255 / 0.2) 40%,
- rgb(237 229 217 / 0.35) 100%
- );
- background-size: 240% 100%;
-}
-.dark .listening-card-glow {
- background-image: linear-gradient(
- 125deg,
- rgb(52 42 34 / 0.35) 0%,
- rgb(37 32 24 / 0.15) 45%,
- rgb(58 48 39 / 0.4) 100%
- );
-}
.listening-art-live {
animation: listening-soft-breathe 6s ease-in-out infinite;
@@ -739,12 +216,6 @@ body {
}
-/* ── Nav wave underline (portal in nav.tsx) ── */
-@keyframes nav-wave-flow {
- from { background-position-x: 0px; }
- to { background-position-x: 20px; }
-}
-
/* ── iMessage-style letter wave (used by jump-text.tsx) ─────── */
@keyframes letter-jump {
0%, 14%, 100% { transform: translateY(0); }
@@ -755,49 +226,6 @@ body {
animation: letter-jump 3.6s cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
}
-/* ── ":D" giggle — grows and wiggles right after the wave ───── */
-@keyframes emote-giggle {
- 0%, 18%, 42%, 100% { transform: scale(1) rotate(0deg); }
- 24% { transform: scale(1.32) rotate(5deg); }
- 30% { transform: scale(1.12) rotate(-4deg); }
- 36% { transform: scale(1.24) rotate(3deg); }
-}
-.emote-giggle {
- display: inline-block;
- transform-origin: 55% 65%;
- animation: emote-giggle 3.6s ease-in-out infinite;
-}
-
-@media (prefers-reduced-motion: reduce) {
- .jump-letter,
- .emote-giggle { animation: none; }
-}
-
-/* ── Highlight.js syntax theme (notes section) ──────────────── */
-.hljs { color: #24292e; background: transparent; }
-.hljs-keyword, .hljs-operator { color: #d73a49; font-weight: bold; }
-.hljs-string, .hljs-doctag { color: #032f62; }
-.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
-.hljs-title, .hljs-section { color: #6f42c1; font-weight: bold; }
-.hljs-variable, .hljs-template-variable, .hljs-attr { color: #005cc5; }
-.hljs-number, .hljs-literal { color: #005cc5; }
-.hljs-built_in, .hljs-builtin-name { color: #e36209; }
-.hljs-type, .hljs-class .hljs-title { color: #6f42c1; }
-.hljs-params { color: #24292e; }
-.hljs-meta { color: #735c0f; }
-
-.dark .hljs { color: #e6edf3; }
-.dark .hljs-keyword, .dark .hljs-operator { color: #ff7b72; }
-.dark .hljs-string, .dark .hljs-doctag { color: #a5d6ff; }
-.dark .hljs-comment, .dark .hljs-quote { color: #8b949e; }
-.dark .hljs-title, .dark .hljs-section { color: #d2a8ff; font-weight: bold; }
-.dark .hljs-variable, .dark .hljs-template-variable, .dark .hljs-attr { color: #79c0ff; }
-.dark .hljs-number, .dark .hljs-literal { color: #79c0ff; }
-.dark .hljs-built_in, .dark .hljs-builtin-name { color: #ffa657; }
-.dark .hljs-type, .dark .hljs-class .hljs-title { color: #d2a8ff; }
-.dark .hljs-params { color: #e6edf3; }
-.dark .hljs-meta { color: #e3b341; }
-
@media (prefers-reduced-motion: reduce) {
.subpage-enter::before {
animation: none;
@@ -808,41 +236,8 @@ body {
opacity: 1;
transform: none;
}
- .listening-eq-bar-active {
- animation: none;
- transform: scaleY(0.72);
- opacity: 1;
- }
- .listening-live-dot {
- animation: none !important;
- }
- .listening-wave-orb {
- animation: none;
- transform: translateX(8px) scale(0.85);
- opacity: 0.85;
- }
- .listening-fx-bar,
- .listening-fx-comet,
- .listening-fx-radar::before,
- .listening-fx-radar::after,
- .listening-fx-ribbon-line,
- .listening-fx-spark,
- .listening-fx-strobe,
- .listening-fx-crest-peak,
- .listening-fx-shard,
- .listening-fx-pendulum-dot,
- .listening-fx-drop,
- .listening-fx-moire-line,
- .listening-fx-braid-node,
- .listening-fx-glyph::before,
- .listening-fx-glyph::after {
- animation: none !important;
- }
- .listening-card-glow {
- animation: none;
- background-position: 50% 50%;
- background-size: 100% 100%;
- }
+ .jump-letter,
+ .listening-live-dot,
.listening-art-live {
animation: none !important;
}
diff --git a/app/layout.tsx b/app/layout.tsx
index 794f2d0..35dca79 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -9,6 +9,7 @@ import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import "./globals.css";
import Nav from "./components/nav";
+import SiteFooter from "./components/site-footer";
import SubpageEnter from "./components/subpage-enter";
import Providers from "./components/providers";
import { Analytics } from "@vercel/analytics/react";
@@ -19,10 +20,10 @@ import Script from "next/script";
export const metadata: Metadata = {
metadataBase: new URL("https://kaichen.dev"),
title: "Kai T. Chen",
- description: "Visiting UC Berkeley · Maths at SUSTech",
+ description: "Visiting at UC Berkeley · Maths at SUSTech",
openGraph: {
title: "Kai T. Chen",
- description: "Visiting UC Berkeley · Maths at SUSTech",
+ description: "Visiting at UC Berkeley · Maths at SUSTech",
url: "https://kaichen.dev",
siteName: "Kai T. Chen",
images: [{ url: "/opengraph-image", width: 1200, height: 630 }],
@@ -32,7 +33,7 @@ export const metadata: Metadata = {
twitter: {
card: "summary_large_image",
title: "Kai T. Chen",
- description: "Visiting UC Berkeley · Maths at SUSTech",
+ description: "Visiting at UC Berkeley · Maths at SUSTech",
images: ["/opengraph-image"],
},
};
@@ -62,6 +63,7 @@ export default function RootLayout({
{children}
+
diff --git a/app/lib/substack.ts b/app/lib/substack.ts
deleted file mode 100644
index 4398f54..0000000
--- a/app/lib/substack.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-export type SubstackPost = {
- title: string;
- url: string;
- pubDate: string;
- description: string;
-};
-
-// `https://substack.com/@
/feed` started returning 404 (it serves the
-// generic discover HTML page now), so we hit the publication feed directly.
-const FEEDS = ["https://kaiiiichen.substack.com/feed"];
-
-// Refetch the RSS feed every 10 minutes so newly-published posts appear on the
-// homepage within ~10 min instead of the previous 1 h window.
-const REVALIDATE_SECONDS = 600;
-
-function extractTag(xml: string, tag: string): string {
- const cdataMatch = new RegExp(`<${tag}[^>]*><\\/${tag}>`, "i").exec(xml);
- if (cdataMatch) return cdataMatch[1].trim();
- const plainMatch = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i").exec(xml);
- return plainMatch ? plainMatch[1].trim() : "";
-}
-
-/** Pure RSS XML → posts (first 3 items). Used by `getSubstackPosts` and tests. */
-export function parseSubstackRssXml(xml: string): SubstackPost[] {
- const itemBlocks = xml.match(/- ][\s\S]*?<\/item>/gi) ?? [];
- return itemBlocks.slice(0, 3).map((block) => {
- const title = extractTag(block, "title");
- const url = extractTag(block, "link") || extractTag(block, "guid");
- const pubDate = extractTag(block, "pubDate");
- const rawDesc = extractTag(block, "description").replace(/<[^>]+>/g, "");
- const description = rawDesc.slice(0, 100) + (rawDesc.length > 100 ? "…" : "");
- return { title, url, pubDate, description };
- });
-}
-
-export async function getSubstackPosts(): Promise
{
- for (const feed of FEEDS) {
- try {
- const res = await fetch(feed, {
- next: { revalidate: REVALIDATE_SECONDS },
- headers: { "User-Agent": "kaichen.dev/1.0" },
- });
- if (!res.ok) continue;
- const xml = await res.text();
- const posts = parseSubstackRssXml(xml);
- if (posts.length > 0) return posts;
- } catch {
- // try next feed
- }
- }
- return [];
-}
diff --git a/app/misc/page.tsx b/app/misc/page.tsx
index 015cfef..328892d 100644
--- a/app/misc/page.tsx
+++ b/app/misc/page.tsx
@@ -2,7 +2,7 @@ import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Misc — Kai T. Chen",
- description: "Curated lists: news worth attention, tools and creators I love, remembrance.",
+ description: "Curated lists: watching, tools and creators I love, remembrance.",
};
const THING_GROUPS = [
@@ -109,10 +109,10 @@ export default function Misc() {
- {/* Call for Attention — full width */}
+ {/* Watching — full width */}
-
News
+
Watching
{CALL_FOR_ATTENTION.map(({ title, href, source, date }) => (
- {/* Two-column: Tools (left) | Remembrance (right) */}
-
+ {/* Remembrance — full width */}
+
- {/* Things I Love */}
+ {/* Things I Love — full width */}
+
Things I Love
@@ -188,41 +222,6 @@ export default function Misc() {
))}
-
- {/* Remembrance */}
-
-
diff --git a/app/opengraph-image.tsx b/app/opengraph-image.tsx
index e893b6e..e73fce4 100644
--- a/app/opengraph-image.tsx
+++ b/app/opengraph-image.tsx
@@ -4,5 +4,5 @@ export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;
export default function OGImage() {
- return makeOGImage("Kai T. Chen", "Visiting UC Berkeley · Maths at SUSTech");
+ return makeOGImage("Kai T. Chen", "Visiting at UC Berkeley · Maths at SUSTech");
}
diff --git a/app/page.tsx b/app/page.tsx
index bacfcd1..98ab440 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,9 +2,10 @@ import AvatarCard from "./components/avatar-card";
import JumpText from "./components/jump-text";
import ListeningCard from "./components/listening-card";
import WeatherCard from "./components/weather-card";
-import ProjectStars from "./components/project-stars";
+import PinnedProjectLink from "./components/pinned-project-link";
+import HomeSocialLinks from "./components/home-social-links";
+import GwwcBadge from "./components/gwwc-badge";
import { getPinnedProjects } from "./lib/github-pinned";
-import { getSubstackPosts } from "./lib/substack";
const PERSON_JSON_LD = {
"@context": "https://schema.org",
@@ -33,7 +34,7 @@ const PERSON_JSON_LD = {
} as const;
export default async function Home() {
- const [substackPosts, projects] = await Promise.all([getSubstackPosts(), getPinnedProjects()]);
+ const projects = await getPinnedProjects();
return (
- {/* Social links */}
-
+
@@ -127,14 +71,7 @@ export default async function Home() {
style={{ fontFamily: "'Nunito'", fontWeight: 300, letterSpacing: "-0.02em", lineHeight: 1.1 }}
className="text-zinc-900 dark:text-zinc-100 text-[36px] md:text-[48px]"
>
- Kai Thomas Chen{" "}
-
🔸
+ Kai Thomas Chen
Visiting @ UC Berkeley 2026
@@ -144,10 +81,47 @@ export default async function Home() {
{/* Introductions */}
-
to my personal space at a corner of the human made internet :D
-
Currently, I'm collaborating in a datacenter verification project. I'm also tinkering with some side projects with passion!
-
My dream is to live and work around the world, to see and meet people from different culture backgrounds, and to learn and have experiences that are worth remembering!
-
Recently, I'm reading Mere Christianity by C. S. Lewis . I'm really enjoying it, and C.S. Lewis is the man who could break down things and ask underlying questions that are worth thinking about!
+
+
+
+
+
+
+ I like tinkering stuuuuff(!) — and occasionally shipping things that work.
+
+
+ I'm into building things for humans, good tools, long conversations, reading on
+ flights, and anything with wings.
+
+
+ My dream is to live and work around the world, to see and meet people from different culture
+ backgrounds, and to learn and have experiences that are worth remembering!
+
+
+ Recently, I'm reading{" "}
+
+ Mere Christianity
+ {" "}
+ by{" "}
+
+ C. S. Lewis
+
+ . I'm really enjoying it, and C.S. Lewis is the man who could break down things and ask underlying
+ questions that are worth thinking about!
+
@@ -169,12 +143,8 @@ export default async function Home() {
- {/* ── Layer 3: Projects | Blog ─────────────────────────────── */}
-
-
+ {/* ── Layer 3: Projects ────────────────────────────────────── */}
+
-
- {/* Blog */}
-
-
Blog
- {substackPosts.length === 0 ? (
-
- No posts yet.
-
- ) : (
-
- )}
-
- {/* Footer */}
-
-
);
}
diff --git a/app/projects/page.tsx b/app/projects/page.tsx
index 93dc6a5..e68d6f6 100644
--- a/app/projects/page.tsx
+++ b/app/projects/page.tsx
@@ -1,5 +1,5 @@
import type { Metadata } from "next";
-import ProjectStars from "../components/project-stars";
+import PinnedProjectLink from "../components/pinned-project-link";
import GitHubActivity from "../components/GitHubActivity";
import { getPinnedProjects } from "../lib/github-pinned";
@@ -29,7 +29,6 @@ export default async function Projects() {
>
Things I've built, maintained, or archived with love.
-
{/* Project cards — same repos & order as GitHub profile “Pinned” */}
@@ -42,62 +41,8 @@ export default async function Projects() {
Pin repositories on your GitHub profile to show them here.
) : (
- projects.map(({ name, desc, href, repo, stack, stars, archived }) => (
-
- {/* Name row */}
-
-
- {/* Description */}
- {desc ? (
-
- {desc}
-
- ) : null}
-
- {/* Footer: stack + GitHub */}
-
-
- {stack.map((tag) => (
-
- {tag}
-
- ))}
-
-
- GitHub ↗
-
-
-
+ projects.map((project) => (
+
))
)}
diff --git a/lib/listening-monthly-top.test.ts b/lib/listening-monthly-top.test.ts
new file mode 100644
index 0000000..3af7750
--- /dev/null
+++ b/lib/listening-monthly-top.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { getLastMonthUtcRange, pickTopFiveTracks } from "./listening-monthly-top";
+
+describe("getLastMonthUtcRange", () => {
+ it("returns previous calendar month bounds", () => {
+ const { start, end } = getLastMonthUtcRange(new Date("2026-03-15T12:00:00.000Z"));
+ expect(start).toBe("2026-02-01T00:00:00.000Z");
+ expect(end).toBe("2026-03-01T00:00:00.000Z");
+ });
+});
+
+describe("pickTopFiveTracks", () => {
+ it("returns top five by play count without counts in output", () => {
+ const rows = [
+ { track_id: "a", track_name: "A", artist_name: "X", album_art: "", song_url: "https://a" },
+ { track_id: "b", track_name: "B", artist_name: "Y", album_art: "", song_url: "https://b" },
+ { track_id: "a", track_name: "A", artist_name: "X", album_art: "", song_url: "https://a" },
+ { track_id: "a", track_name: "A", artist_name: "X", album_art: "", song_url: "https://a" },
+ { track_id: "c", track_name: "C", artist_name: "Z", album_art: "", song_url: "https://c" },
+ ];
+ const top = pickTopFiveTracks(rows);
+ expect(top).toHaveLength(3);
+ expect(top[0]).toEqual({
+ title: "A",
+ artist: "X",
+ albumArt: "",
+ songUrl: "https://a",
+ });
+ expect(top.every((t) => !("count" in t))).toBe(true);
+ });
+});
diff --git a/lib/listening-monthly-top.ts b/lib/listening-monthly-top.ts
new file mode 100644
index 0000000..8cac69c
--- /dev/null
+++ b/lib/listening-monthly-top.ts
@@ -0,0 +1,54 @@
+export type ListeningHighlightTrack = {
+ title: string;
+ artist: string;
+ albumArt: string;
+ songUrl: string;
+};
+
+export type ListeningHistoryPlayRow = {
+ track_id: string;
+ track_name: string;
+ artist_name: string;
+ album_art: string;
+ song_url: string;
+};
+
+/** Previous calendar month in UTC — [start, end) ISO bounds for Supabase filters. */
+export function getLastMonthUtcRange(now = new Date()): { start: string; end: string } {
+ const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
+ const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
+ return { start: start.toISOString(), end: end.toISOString() };
+}
+
+/** Rank by play count; ties keep first-seen order. Returns at most five tracks (no counts). */
+export function pickTopFiveTracks(rows: ListeningHistoryPlayRow[]): ListeningHighlightTrack[] {
+ const ranked = new Map<
+ string,
+ { track: ListeningHighlightTrack; count: number; order: number }
+ >();
+ let order = 0;
+
+ for (const row of rows) {
+ const key = row.track_id || `${row.track_name}::${row.artist_name}`;
+ const existing = ranked.get(key);
+ if (existing) {
+ existing.count += 1;
+ } else {
+ ranked.set(key, {
+ track: {
+ title: row.track_name,
+ artist: row.artist_name,
+ albumArt: row.album_art,
+ songUrl: row.song_url,
+ },
+ count: 1,
+ order: order++,
+ });
+ }
+ }
+
+ return [...ranked.values()]
+ .sort((a, b) => b.count - a.count || a.order - b.order)
+ .slice(0, 5)
+ .map(({ track }) => track);
+}
diff --git a/lib/listening-supabase.ts b/lib/listening-supabase.ts
index ecdb381..1e0e2d3 100644
--- a/lib/listening-supabase.ts
+++ b/lib/listening-supabase.ts
@@ -1,4 +1,10 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
+import {
+ getLastMonthUtcRange,
+ pickTopFiveTracks,
+ type ListeningHighlightTrack,
+ type ListeningHistoryPlayRow,
+} from "./listening-monthly-top";
export function createListeningSupabase(): SupabaseClient | null {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
@@ -28,6 +34,50 @@ export async function getLastPlayedListeningRow(db: SupabaseClient | null | unde
}
}
+export async function getLastMonthTopFiveTracks(
+ db: SupabaseClient | null | undefined
+): Promise {
+ if (!db) return [];
+ const { start, end } = getLastMonthUtcRange();
+ try {
+ const rows = await fetchListeningHistoryRowsInRange(db, start, end);
+ if (!rows.length) return [];
+ return pickTopFiveTracks(rows);
+ } catch {
+ return [];
+ }
+}
+
+const HISTORY_PAGE_SIZE = 1000;
+
+/** `listening_history` uses `recorded_at` (not `played_at`). Paginate past PostgREST's 1k row cap. */
+async function fetchListeningHistoryRowsInRange(
+ db: SupabaseClient,
+ start: string,
+ end: string
+) {
+ const rows: ListeningHistoryPlayRow[] = [];
+ let offset = 0;
+
+ while (true) {
+ const { data, error } = await db
+ .from("listening_history")
+ .select("track_id, track_name, artist_name, album_art, song_url")
+ .gte("recorded_at", start)
+ .lt("recorded_at", end)
+ .range(offset, offset + HISTORY_PAGE_SIZE - 1);
+
+ if (error) throw error;
+ if (!data?.length) break;
+
+ rows.push(...data);
+ if (data.length < HISTORY_PAGE_SIZE) break;
+ offset += HISTORY_PAGE_SIZE;
+ }
+
+ return rows;
+}
+
type ListeningStatsUpsertPayload = {
track_id: string;
track_name: string;
diff --git a/lib/substack-rss.test.ts b/lib/substack-rss.test.ts
deleted file mode 100644
index 6f7fdae..0000000
--- a/lib/substack-rss.test.ts
+++ /dev/null
@@ -1,35 +0,0 @@
-import { describe, expect, it } from "vitest";
-import { parseSubstackRssXml } from "@/app/lib/substack";
-
-describe("parseSubstackRssXml", () => {
- it("parses item fields from CDATA-wrapped tags", () => {
- const xml = `
-
--
-
]]>
-
-Mon, 01 Jan 2024 12:00:00 GMT
-First paragraph more text here]]>
-
- `;
- const [post] = parseSubstackRssXml(xml);
- expect(post.title).toBe("Hello & ");
- expect(post.url).toBe("https://example.com/post-1");
- expect(post.pubDate).toBe("Mon, 01 Jan 2024 12:00:00 GMT");
- expect(post.description).toBe("First paragraph more text here");
- });
-
- it("parses plain (non-CDATA) tags and truncates long descriptions", () => {
- const longBody = "x".repeat(120);
- const xml = `-
-
Plain title
- https://example.com/p2
-Tue, 02 Jan 2024 00:00:00 GMT
-${longBody}
- `;
- const [post] = parseSubstackRssXml(xml);
- expect(post.title).toBe("Plain title");
- expect(post.description).toHaveLength(101);
- expect(post.description.endsWith("…")).toBe(true);
- });
-});