Case Study

這個網站是怎麼做的?

// 完整閱讀此頁面約 8 分鐘

這個網站本身就是第一個作品集項目。 它要回答的問題只有一個:一個 Design Engineer,如何把一份 Pencil 設計稿轉化為架構清楚、可維護的 React 前端實作?

以下記錄的不是功能清單,而是每個關鍵取捨背後的理由。 設計稿的數值怎麼被提取成 token、token 又怎麼約束元件、元件如何被分解、RWD 斷層如何劃定、瀏覽器相容問題如何面對、上線如何控制、設計如何被優化 —— 每一步其實都有取捨,而這個網站就是我把那些取捨實際做出來的結果。

設計工程流程

Code First,
但不是 Design Last。

這個網站的設計工程流程不是所有變更都從設計稿開始。結構性與工程性的變更,例如元件拆分、RWD 斷層、瀏覽器相容性修補,會先發生在 .tsx 檔案裡;等實作穩定後,再回推更新 Pencil 設計稿(.pen)。這樣做是為了避免「設計稿改了,但程式碼沒有跟上」的飄移。取捨是設計稿不一定永遠是最新版本,但程式碼會維持為最終落地的依據。

但視覺質感的打磨,會走另一條路。像色彩要不要再淡一點、間距要不要再鬆一點、陰影是否太重、畫面是不是更有呼吸感,這些判斷需要快速比較、反覆觀看,也需要設計者自己的感受介入。對我來說,這類調整在 Pencil 裡完成,通常比直接從程式碼端修改更有效率;即使有 AI 輔助,微妙的視覺判斷也還是需要回到畫面裡確認。定稿後,再把結果落回 token、元件與樣式檔。

所以這套流程其實是分流的:工程結構以程式碼先行,視覺細節以設計稿打磨,最後再回到同一套實作裡收斂。所有 token 數值都必須能追溯回 Pencil 裡的設計鎖點,或後續明確的視覺調整,而不是在程式碼裡憑空發明數字。這讓「為什麼間距是 28px 而不是 24px」有明確答案:它來自一次設計決策,而不是某次隨手調整。

維護同步需要紀律,不只是工具。每次修改 token,tokens.tsglobals.css @themedocs/design.md 三個檔案必須同步更新。三者任一落後,設計意圖就開始靜默地走偏。

設計優化

設計不是一次定稿,
是來回磨出來的。

想優化某處時,我習慣先用程式碼把概念做出來 —— 真實的互動與動態,只有跑起來才看得準,靜態稿騙不了手感。如果視覺還要再磨,才把那個局部丟回 Pencil,請它產出幾個方向的變體並列比較,必要時人工介入調整。定案後再回寫程式碼。Pencil 是探索與溝通的畫布,程式碼始終是唯一真實來源。

首頁「關於我」那顆展開按鈕 ↗就是這樣磨出來的:先有可運作的 read_more() 初稿,再用 Pencil 來回試 chip 尺寸、箭頭、hover 態與間距的變體,挑定後回寫 —— 設計與工程不是兩段交棒,而是在同一個開發迭代裡來回校準,把「能動、看起來對、可上線」收成一件事。

設計和程式的來回對齊4
01
程式碼初稿概念先在 .tsx 跑起來,看真實互動與動態,而非靜態設計稿
02
Pencil 探索變體把局部丟回設計稿,請 Pencil 產出多個方向並列比較
03
人工微調必要時手動修正細節(例:展開按鈕的 chip 與箭頭)
04
回寫程式碼定案回到 .tsx,程式碼是唯一真實來源
Design Token

命名看用途,
不看顏色代碼。

Token 的命名策略不是描述顏色本身,而是描述它在介面中的用途。調色盤不叫「淺藍、深灰」,而是分成四個語意群組:背景層(base / soft / contact)、前景層次(strong → faint 五級)、強調色(含 hover / active 狀態)、邊框。

這讓元件開發時的決定從「我要用哪個藍」變成「這裡需要 accent 還是 border-soft」 —— 選擇空間縮小,決定速度加快。間距 scale 刻意不走標準倍數:第 4 階是 18px,第 5 階是 28px —— 這些數字不是照表套出來的,而是設計反覆調整後留下來的結果。

設計 token 一覽6
01
Color token13 個命名 token,分 bg / fg / accent / border 四組語意群
02
Typography token8 組文字 token(display-xl 到 caption),每組定義 size、weight、letter-spacing、line-height
03
display-xl320px / 900 weight / -12px letter-spacing — Hero 的「Luke」大字
04
Spacing scale12 組 spacing token(4–140px),數值從設計稿反推,非 4px 倍數系統
05
Shadow mapping從 Pencil 特效規格反算:blur:48 + offset.y:24 → CSS shadow;alpha 從 hex #00000021(≈ 0.13)轉換
06
Tailwind integrationv4 CSS-first:token 寫在 @theme block,生成 bg-accent、text-fg-strong 等 utilities,無 tailwind.config.ts
元件設計

職責邊界,
比急著抽象更重要。

Hero section 是這個網站裡拆分邏輯最密集的地方。Hero.tsx不負責把所有畫面細節都渲染出來,它只做一件事:判斷目前要使用哪套版型,然後把內容交給對應的子元件。容器元件的責任是編排與分派,不是把所有視覺邏輯都收進自己身上。

桌機與手機的版型差異大到無法靠同一套 CSS 優雅處理。我的取捨是維護兩棵獨立的 render tree,讓子元件透過 variant 接收情境,各自處理自己的佈局。這會增加一些重複程式碼,但換來的是明確的責任邊界:子元件不用知道lg 斷點在哪裡,也不用在 children 裡藏一堆斷點判斷。

同樣的原則也用在 navbar 上。NavBarBase只保留 sticky 與毛玻璃外殼,各頁透過 children 注入自己的 inner row —— 共用外殼、各自決定列裡放什麼。這和 variant 是處理差異的一體兩面:variant 讓同一個元件依情境在內部分叉,children 則把會變的部分整個交給呼叫端。首頁與 case study 的 navbar 結構差異太大,硬抽成同一個元件反而會變成錯誤抽象。與其急著共用,不如先把差異看清楚,只抽出穩定、可共用、且不造成視覺差異的最小部分。這樣的 refactor 才是真的降低複雜度,而不是把複雜度藏進元件裡。

Hero.tsx 元件樹15 nodes
1Hero.tsx// 46 行,版型路由 + navbar 統合2  ├─ NavBarBase// sticky frosted 外殼(ADR-009)3    ├─ hidden min-[1440px]:flex// 桌機 topbar4      └─ HeroTopBar// 導覽5    ├─ min-[1440px]:hidden// 手機 topbar6      └─ HeroTopBar variant='mobile'7  ├─ <section overflow-hidden>// min-[1440px]:mt-[-96px]8    ├─ hidden min-[1440px]:block// 桌機絕對佈局 1440×9609      ├─ HeroBigText// 負片跑馬燈,left-1/2 -ml-[50vw] 全出血10      ├─ HeroPhoto// 照片11      └─ HeroCorners// 職銜與地點12    ├─ min-[1440px]:hidden// 手機/平板流動堆疊13      ├─ HeroPhoto variant='mobile'14      ├─ HeroBigText variant='mobile'15      └─ HeroCorners variant='mobile'
RWD · 相容性

斷層在哪裡,
就在那裡劃定。

RWD 最大的問題通常不在最小或最大的螢幕,而是在中間那些容易被忽略的寬度。1024–1439px 之間,Hero 的絕對佈局會被 overflow-hidden 裁切;768–913px 之間,BeyondResume 的雙欄 flex 加上 md:px-12,會意外撐出橫向捲軸。這兩個問題都是在 8 個視窗寬度的手動驗證裡發現,而且修法剛好相反:一個要重新劃定 Hero 的斷點,一個要收斂內容寬度與 padding。

SEO 基礎設施維持最小化,只做真的需要的部分:robots、sitemap、canonical URL 與 OG Meta。OG Image 走 edge runtime 動態生成,不預先產出靜態圖片;sitemap 的 lastModified 每次 build 自動更新,不需要手動維護。

上線後的跨環境驗收,又補上了設計稿看不到的問題。iOS Safari favicon 的快取沒有可預期的過期時間,cache key 又吃完整 URL。已經污染在舊裝置上的快取不受我們控制,與其追著清,不如把範圍收斂成可控的部分 —— 確保新裝置正確顯示,並在網址後加上 query(cache key 含 query,等於另開一筆 entry)來驗證。跑馬燈則遇到 WebKit compositing bug,偶發閃爍無法完全消除;最後用 translate3d 降低發生頻率,並把它標記成已知妥協。至於微互動,一律用純 CSS,避免常駐 requestAnimationFrame—— 我曾經因為粒子迴圈讓單一分頁吃到 9GB。從那之後我更保守看待動畫:不是跑得起來就好,還要確認它不會吃掉使用者的資源。

工程細節與已知問題10
01
Hero 斷層策略≥1440px:依 Pencil 設計稿做精確尺寸與絕對定位;<1440px:改用流動堆疊。取捨是維護兩套 HTML,避免在同一棵 render tree 裡硬塞過多斷點判斷。
02
BeyondResume 修復雙欄版型觸發點從 md 調整到 lg(768→1024px),讓內容在進入雙欄前先收斂為單欄,消除 768–913px 區間的橫向捲軸。
03
移動端內邊距全站移動端統一使用 px-6(24px),確保 375px 最窄視窗下內容不會撐出橫向捲軸。
04
OG Image使用 edge runtime 動態生成 1200×630 PNG,透過 Next.js 原生 ImageResponse 輸出,不需要額外維護靜態圖片或 CDN。
05
SEO 基礎設施robots.ts、sitemap.ts、canonical URL 與 OG Meta 四層就夠用;只做必要項目,避免為了完整感堆出過量設定。
06
sitemap 維護lastModified 在每次 build 時自動更新為當天日期,避免每次內容調整後還要手動同步 sitemap。
07
overscroll 兩端補色橡皮筋回彈區只填單一根背景色,且 Chromium 取 <html>、Safari 取 <body>;依捲動位置切 data-overscroll,html+body 兩邊背景同步切換,達成頂白、底 soft 色。
08
iOS Safari faviconWebpageIcons.db 沒有明確文件化 TTL,cache key 吃完整 URL(含 query)。舊裝置的污染快取不可控,取捨是收斂範圍:只保證新裝置正確顯示,並以 ?x=1 query 驗證(新 entry,繞過舊污染)。另備 .ico fallback 處理 Safari 的格式相容問題。
09
跑馬燈 WebKit flicker以 translate3d、will-change、backface-visibility 降低 WebKit compositing flicker,將症狀從約 30 秒一次延後到約 2 分鐘一次。根因無法完全消除,因此標記為已知妥協。
10
效能紀律微互動一律優先使用純 CSS,不啟動常駐 requestAnimationFrame。曾因常駐粒子迴圈讓單一分頁吃到 9GB 記憶體;能動和不耗電是兩件事。
部署

值得記錄的細節。

技術選型本身很單純:Next.js 16 App Router、TypeScript、Tailwind v4 與 Vercel。真正值得記錄的,不是用了哪些工具,而是上線後那些容易被忽略的細節怎麼處理。第一個例子,是 PDF 更新日期。

Vercel 部署時,git checkout 會重置檔案的 mtime,所以如果直接讀取 resume.pdf 的修改時間,production 上永遠會顯示部署當天,而不是履歷真正更新的時間。我的解法是在 next.config.ts 裡,於 build time 執行 git log -1 --format=%cd --date=format:%Y.%m,從 git commit 紀錄抓出 PDF 最後更新的月份,再注入成環境變數。這樣不管網站重新部署幾次,畫面上顯示的都會是那份 PDF 最後被更新的月份,而不是部署時間。

另一個細節是上線開關。正式網域 hire.lukelin.dev/* 前面先掛一層 Cloudflare Worker 作為 coming-soon gate:啟用時回應 503,並加上 noindex,避免搜尋引擎收錄半成品;停用時,流量就直接回到 Vercel,正式上線。這個做法讓上線切換不需要重新 build,也不需要重新部署。staging 的 vercel.app URL 不經 Cloudflare,仍然可以獨立驗收。

技術棧與部署設定7
01
FrameworkNext.js 16 App Router(Turbopack),不使用 Pages Router
02
LanguageTypeScript strict,React 19.2.4
03
StylingTailwind v4 CSS-first,以 @theme block 定義 token,不維護 tailwind.config.ts
04
DeploymentVercel 部署,main 分支推送後自動觸發 next build
05
PDF 日期注入next.config.ts 在 build time 從 git log 提取 YYYY.MM,注入 RESUME_UPDATED env,避免 Vercel 部署時重置檔案 mtime 導致日期失真
06
上線開關Cloudflare Worker 掛在 hire.lukelin.dev/* route:啟用時回應 503 並加上 noindex,停用後流量直達 Vercel;可在 Cloudflare Dashboard 一鍵切換,零 build、零部署
07
網域策略lukelin.dev 採子網域制,求職站部署於 hire.lukelin.dev;正式流量收斂到自有網域,Vercel preview URL 則保留作為獨立驗收環境

看完了?如果你也在找一個能把設計判斷落到前端實作的人,歡迎聯絡。

← 回首頁瀏覽履歷