用 Claude Code 一天重建 SaaS 儀表板 — Tailwind v4 + CSS Grid 表格對齊
- engineering
- tailwind
- css-grid
- dark-mode
- claude-code
TL;DR
- 問題:15 位新用戶中有 11 位(73%)完成註冊後卻沒有新增任何 URL 就離開了
- 假設:我們類別中的每款熱圖工具預設都使用淺色 UI——我們的深色儀表板可能造成困惑與早期流失
- 我們做了什麼:一天內完成完整儀表板重設計——淺色 / 深色 / 系統三向切換 + GitHub 風格表格列表
- 學到的事:Tailwind v4 的
@custom-variant、CSS Gridauto欄陷阱、對抗長文字的三層防禦,以及為何 10–16 次設計迭代是常態而非例外- 結果:流失率影響待評估;兩週數據後將發布後續報告
背景:我們是否因為儀表板太暗而流失用戶?
HeatMapX(heatmapx.com)是一款可透過 Claude Code CLI 呼叫的熱圖與 CRO 工具。在展開第一波推廣後,我們發現了一個規律:15 位新用戶中有 11 位(73%)完成了註冊,卻沒有新增任何一個 URL 就離開了。
其中一個假設:我們類別中所有主要熱圖工具都預設使用淺色 UI。HeatMapX 卻是以深色管理介面示人的異類。第一次進入的用戶在一片白色主題的應用程式中看到深色儀表板,可能就因為困惑而跳出了。
於是我們與 Claude Code 合作,在一個工作階段內完成了完整重設計。本文涵蓋假設驗證的前半部分——診斷流失問題並執行重設計。後續篇章將在部署後兩週、取得實際數據後發布。
最終設計配置
| 元素 | 淺色 | 深色 |
|---|---|---|
| 頁面背景 | bg-neutral-100(#f5f5f5) |
dark:bg-neutral-950(#0a0a0a) |
| 卡片表面 | bg-white + border-slate-200 |
dark:bg-slate-900 + dark:border-slate-800 |
| 主要文字 | text-slate-900 |
dark:text-slate-100 |
| 主色(CTA) | bg-orange-600(#ea580c) |
兩種模式共用 |
| 圓角半徑 | rounded-md(6px)· 無陰影 · GitHub 風格 |
|
| 主題切換 | ☀ / 💻 / 🌙 三向切換置於 header |
Tailwind v4 深色模式——從零配置到手動切換
從 @media prefers-color-scheme 開始
Tailwind v4 的預設行為是將 dark: 變體直接對應到 @media (prefers-color-scheme: dark)。開箱即用、零配置,它會直接跟隨作業系統的深色模式設定。
{/* layout.tsx */}
<div className="bg-neutral-100 text-slate-900 dark:bg-neutral-950 dark:text-slate-100">
...
</div>
這樣就能處理「跟隨系統」的情境(例如夜間自動切換深色),完全不需要額外設定。但用戶有時會想覆蓋系統設定——「系統」模式可能判斷有誤,或純粹是個人偏好——因此我們將其擴充為三向切換。
切換為以 class 為基礎的深色變體
在 Tailwind v4 中,你可以使用 @custom-variant 重新定義 dark: 變體的行為。我們切換到了只有在 <html> 上存在 .dark 時,dark: 才會觸發的模式:
/* globals.css */
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:where() 包裝器將選擇器優先權保持為 0。若不加這個,深色變體樣式可能與樣式表中其他地方的既有優先權規則衝突。
防止 SSR 閃爍的同步腳本
如果在 React 水合(hydration)完成後才在 useEffect 中套用主題,會產生未樣式化內容閃爍(FOUC)——首次渲染時短暫出現白色閃光。解決方式是在 <head> 中注入一段行內同步腳本,使其在 body 渲染前執行:
{/* app/layout.tsx */}
<head>
<script dangerouslySetInnerHTML={{ __html: `
(function(){try{
var t = localStorage.getItem('theme') || 'system';
var d = t === 'dark' || (t === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (d) document.documentElement.classList.add('dark');
}catch(e){}})();
`}} />
</head>
這段程式碼在 React 掛載前同步執行,因此當 body 開始渲染時,.dark 已經在 <html> 元素上了。在 <html> 上加入 suppressHydrationWarning 以消除水合不匹配的警告。
💡 學習 1:Tailwind v4 的
dark:零配置即可運作。如果只需要跟隨 OS,完全不需要動任何東西。只有在明確需要用戶手動切換時,才加入@custom-variant+ 同步腳本——這是一個乾淨的兩階段做法。
CSS Grid 表格對齊——以及 auto 欄如何咬你一口
單欄列表不夠用
在收到「新增網站後的網站卡片感覺不對」的反饋後,我們先嘗試了顯而易見的做法:把兩欄卡片格換成單欄列表。每個 SiteCard 內部使用 flexbox 來排列 name · url · status · events,右側有一個箭頭符號。
問題在多個項目出現時就暴露了:
Short · example.com · ● Active · 5,577
Very Long Site Name That... · normal.com · ● Active · 5,577
ACME · acme.io · ● Active · 5,577
· Active、· 5,577 和 › 箭頭在每一行都落在不同的 x 座標。眼睛無法錨定「狀態欄在這裡」。這不像一個列表——它讀起來像是一堆垂直堆疊的字串。
這根本不是列表,是文字,垂直堆疊。
轉換為 CSS Grid 表格佈局
我們用固定欄位定義的 CSS Grid 取代了 flexbox 內部結構。由於每張卡片共用相同的 grid 模板,欄位左邊緣在整個頁面上完美對齊:
// SiteCard.tsx
<Link className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] items-center gap-x-4 ...">
<h3 className="truncate">{name}</h3>
<span className="truncate">{displayUrl}</span>
<span className="truncate font-mono">{apiKey}</span>
<span className="truncate font-mono">{trackerSnippet}</span>
<span>● Active</span>
<span>{count}</span>
<span>›</span>
</Link>
標頭列使用完全相同的 grid 模板:
// page.tsx 標頭列
<div className="grid grid-cols-[minmax(0,2fr)_minmax(0,2fr)_minmax(0,1.5fr)_minmax(0,3fr)_110px_72px_20px] gap-x-4 ...">
<span>SITE</span>
<span>URL</span>
<span>API KEY</span>
<span>TRACKER TAG</span>
<span>STATUS</span>
<span>TODAY</span>
<span></span>
</div>
陷阱:不同 grid 容器中的 auto 欄各自獨立計算尺寸
我們的第一版使用了 grid-cols-[minmax(0,2fr)_minmax(0,2fr)_auto_auto_auto]。當時的想法是:「auto 會依據內容調整,而且因為標頭和 SiteCard 使用相同的模板字串,它們應該會對齊。」
它們沒有對齊。原因如下:
- 標頭的
auto欄:依據 "STATUS" 文字寬度計算 - SiteCard 的
auto欄:依據 "● Active" 文字寬度計算 - 兩者都是獨立的 grid 容器——軌道尺寸各自獨立計算
兩種解決方案:
- 固定寬度:用明確的像素值如
110px、72px取代auto(我們最終採用的方案) - CSS Subgrid:將標頭和所有 SiteCard 放在同一個父 grid 內,然後透過
subgrid讓每一列繼承父容器的軌道(更複雜)
💡 學習 2:當你需要跨不同 grid 容器進行表格對齊時,不要使用
auto欄。每個容器依據自身內容獨立計算軌道寬度。請使用固定像素寬度,或透過 subgrid 共享軌道。
對抗長文字——三層防禦
初始的資料庫結構是這樣的:
CREATE TABLE sites (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL, -- 無長度限制
url text NOT NULL, -- 無長度限制
...
);
Postgres 的 text 實際上沒有長度限制。一個 10,000 個字元的網站名稱是完全合法的 SQL——但會立刻摧毀表格佈局。轉換為固定欄位 grid 後,文字長度限制變得不可妥協。
我們最終採用的三層防禦:
- 前端 HTML5 限制:輸入框上的
maxLength={60}/maxLength={512} - 伺服器端驗證:Server Action 檢查
name.length > 60並回傳錯誤 - 顯示截斷:每個 grid 儲存格上的
truncateclass,搭配minmax(0, ...)以啟用溢出處理
第三層是人們最常忽略的。truncate 展開為 overflow: hidden + text-overflow: ellipsis + white-space: nowrap,但在 flex 或 grid 的情境下,儲存格還需要 min-width: 0——否則儲存格會擴張以容納內容,溢出永遠不會觸發。minmax(0, ...) 正好提供了這一點。
16 次設計迭代
光是 SiteCard 在這個工作階段就經歷了 16 次設計迭代。部分關鍵轉折:
| # | 變更 | 原因 |
|---|---|---|
| 1 | 將 CopyBlock 轉換為淺灰色膠囊樣式 | 黑色背景 + 綠色文字在 GitHub 淺色模式下看起來很奇怪 |
| 3 | A/B/C/D 四選項比較 | 需要更強的點擊引導 |
| 4 | 切換為單欄列表 | 被否決為「太花俏」——回歸基本 |
| 7 | 移除「View Heatmap →」CTA | 「看起來不夠乾淨」 |
| 9 | 轉換為 CSS Grid 表格佈局 | 以分隔符號為基礎的字串佈局「感覺不像列表」 |
| 10 | auto → 固定像素寬度 |
標頭和卡片的欄位沒有對齊 |
| 11 | 恢復 API KEY 欄 | 「等等,API key 在哪裡?」 |
| 12 | 新增 TRACKER TAG 欄 | 「把兩個並排放」 |
| 14 | 新增 maxLength + 伺服器驗證 | 針對長文字健壯性的正確修復 |
| 16 | 在標題旁新增「+」按鈕(緊湊模式) | 不需捲動就能快速新增 |
⚠ 不要期望一次就能成功。 設計迭代意味著「在開發伺服器中檢查 → 立即反饋 → 下一次嘗試」,重複 10+ 次。請預先規劃這個循環。像 Claude Code 這樣的互動式 AI 開發環境正是讓這個模式真正發光的地方。
與 AI 輔助開發搭配時真正有效的模式
這些不是工具特有的觀察——而是與互動式 AI 配對時被證實有效的通用模式:
- 新 API 語法的查找成本為零:Tailwind v4 的
@custom-variant相對較新。Claude Code 立即以可運行的形式提供了它,無需翻閱文件。 - 實作 → 驗證 → 修復的循環在秒內完成:重現 CSS Grid
auto未對齊問題、隔離原因、交付修復,總共只花了約 5 分鐘。 - 機械性的批量工作蒸發了:在單次迭代中替換 15 個語系檔案中的佔位文字(
in-gol→example.com)。 - 與 HMR 的親和性:編輯 → 儲存 → 瀏覽器即時刷新 → 反饋 → 再次編輯。這個循環夠快,讓你保持在心流狀態。
- 一個工作階段端到端完成:從完成重設計直接到推送到正式環境並觀看 Vercel 部署——沒有任何情境切換。
下一節涵蓋效果較差的部分。
哪些地方困難 / 還有什麼待完成
- 將模糊的反饋轉化為結構性決策讓迭代次數倍增。「這感覺不對」必須先變成「表格 vs. 列表 vs. 卡片」才能進行任何有用的工作。事先達成結構共識可以節省 3–4 次迭代。
- 在第 10 次迭代前後,需求不斷擴展——「全部內聯」、「全部截斷」、「所有欄位」——而某些限制確實存在張力(資訊密度 vs. 可瀏覽性)。沒有捷徑能通往最終形式。
- 行動版尚未處理。 7 欄的 grid 在小螢幕上會產生水平捲動。解決方式是使用
sm:媒體查詢來堆疊欄位,或使用獨立的行動版元件。這是下一個要處理的項目。
結論
在一個工作階段內,我們將 HeatMapX 儀表板從深色卡片格重建為具有 GitHub 風格表格列表的淺色/深色/系統切換佈局。關鍵技術收穫:
- Tailwind v4 深色模式:從
@media基礎的深色模式開始——零配置。只有在用戶需要手動覆蓋時,才加入@custom-variant+ 同步腳本。 - CSS Grid 表格對齊:
auto欄不能跨不同 grid 容器對齊。使用固定像素寬度或 CSS Subgrid——這是唯一可靠的選擇。 - 長文字健壯性:三層,缺一不可——前端
maxLength、伺服器端長度檢查,以及帶minmax(0, ...)的顯示truncate。 - 設計迭代預算:假設每個元件需要 10–16 次迭代,並從一開始就建立快速的反饋循環。
這是否真的能改善流失率仍是一個開放的問題。我們將在兩週實際數據後發布後續報告。