沒有 Tailwind、沒有 UI library,一切要自己寫。如果直接開始寫 .card { border-radius: 14px }、.btn { border-radius: 14px }、.chip { border-radius: 12px },兩週後這個網站會有五種圓角、七種灰色、三種陰影。
解法不是「小心一點」,是把設計決策集中成變數,讓不一致在物理上不可能發生。
index.html → 學習地圖(入口)
course.html → 一門課的章節總覽
chapter.html → 課文 + 測驗
三個真正的 HTML 檔,跨頁用超連結,不寫任何路由。理由:
file:// 可直開是硬需求,SPA 路由在 file:// 下 history.pushState 會很尷尬。history.replaceState 一行解決(Day 6)。<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>學徑 LearnPath — 我的學習地圖</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+TC:wght@400;500;700;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<a class="brand" href="index.html"><span class="logo">Σ</span> 學徑 LearnPath</a>
<div class="nav-spacer"></div>
<div class="nav-progress">
<span id="nav-progress-text">0% 完成</span>
<div class="pbar"><i id="nav-progress-bar" style="width:0%"></i></div>
</div>
</div>
</nav>
<header class="hero hero-compact">
<div class="hero-inner">
<span class="eyebrow">🗺 一張地圖,走完我的學習歷程</span>
<h1>我的<span class="grad">學習地圖</span></h1>
<div class="hero-stats">
<div class="stat"><b id="stat-courses">—</b><span>門課程</span></div>
<div class="stat"><b id="stat-lessons">—</b><span>課程單元</span></div>
<div class="stat"><b id="stat-done">0</b><span>已完成</span></div>
</div>
</div>
</header>
<main class="wrap map-wrap">
<div id="course-map"></div> <!-- Day 4 由 JS 填入 SVG -->
</main>
<footer class="footer">學徑 LearnPath · 進度儲存在你的瀏覽器中</footer>
<!-- body 收尾依序載入五支 script(models → views → controllers):
js/models/curriculum.js
js/models/progress.js
js/views/nav-view.js
js/views/map-view.js
js/controllers/home.js
每支都是一行 script 標籤、src 指到對應檔;完整寫法見 repo。 -->
</body>
</html>
注意 HTML 裡有一堆空容器 + id(#course-map、#stat-lessons)。這是沒有框架時的模式:HTML 提供插槽,JS 負責填。插槽的 id 就是 View 與 HTML 之間的契約。
這幾支 script 標籤的載入順序是硬性契約:models → views → controllers。不用 ES modules 的代價就是要自己顧順序(type="module" 在 file:// 有 CORS 限制,會直接讓雙擊開檔壞掉)。
:root 就是設計系統:root {
--bg: #f8fafc;
--surface: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--text-2: #475569;
--text-3: #94a3b8;
--accent: #4f46e5;
--accent-soft: #eef2ff;
--green: #16a34a;
--amber: #d97706;
--red: #dc2626;
--radius: 14px;
--shadow: 0 1px 3px rgba(15,23,42,.06), 0 4px 16px rgba(15,23,42,.05);
--shadow-lg: 0 4px 12px rgba(15,23,42,.08), 0 12px 32px rgba(15,23,42,.10);
}
規則:元件樣式裡不准出現字面色碼。全站只有 :root 有 hex。這條紀律在第 11 天做深色模式時,會讓我只需要改一個區塊、不用碰任何元件。
字體堆疊要照顧中文,順序有講究:
body {
font-family: "Noto Sans TC", -apple-system, BlinkMacSystemFont,
"Segoe UI", "PingFang TC", "Microsoft JhengHei", sans-serif;
line-height: 1.75; /* 中文長文的舒適行高,比英文預設高 */
-webkit-font-smoothing: antialiased;
}
line-height: 1.75 不是隨手寫的。中文沒有字母高低差,行距太窄會糊成一團;這是全站可讀性最便宜的一個改善。
網站有六個學科類別(基礎、統計、機器學習、研究、資料工程、雲端),每類一個顏色。最直覺的寫法是:
/* ❌ 不要這樣 */
.cat-ml { color: #e11d48; }
.node-ml { stroke: #e11d48; }
.chip-ml { background: #e11d48; }
問題:地圖節點是 SVG,由 JS 動態產生(Day 4),JS 拿不到 CSS class 裡的色碼。我會被迫在 JS 裡再寫一次 #e11d48,然後某天只改了其中一處。
所以顏色定義在資料層:
// js/models/curriculum.js
const CAT_META = {
foundation: { label: "基礎", color: "#4f46e5" },
stats: { label: "統計方法", color: "#a21caf" },
ml: { label: "機器學習", color: "#e11d48" },
research: { label: "研究主題", color: "#475569" },
eng: { label: "資料工程", color: "#0d9488" },
cloud: { label: "雲端", color: "#d97706" },
};
View 需要顏色就去查:
const color = CAT_META[course.cat].color;
// SVG 描邊、標題文字、淡背景全部從這一個來源展開
`<div class="course-icon" style="background:${color}18;color:${color};border-color:${color}55">`
${color}18 是個小把戲:在 6 位 hex 後面接兩位就成了 8 位 hex(含 alpha),18 ≈ 9% 不透明度。同一個色碼可以展開成「淡背景 / 中等邊框 / 實心文字」三種用法,不需要為每個類別各定義三個變數。
.card, .chapter-card, .content-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
/* 進度條:全站共用一個 .pbar,只有寬度是動態的 */
.pbar { height: 6px; background: var(--border); border-radius: 99px; overflow: hidden; }
.pbar > i { display: block; height: 100%; background: var(--accent); transition: width .3s; }
.pbar 出現在頂欄、課程頁、章節卡三個地方,寫一次就好。JS 只需要設 style.width。
@media (max-width: 880px) {
.chapter-layout { grid-template-columns: 1fr; } /* sidebar 收成單欄 */
.nav-progress { display: none; }
}
我只設一個斷點 880px(課文頁 sidebar 收起來的臨界點)。不要一開始就鋪三四個斷點——那是在為還沒發生的問題寫程式。
Google Fonts 的兩個坑。
<link rel="preconnect" href="https://fonts.googleapis.com"> 的話,字型請求要等 DNS + TLS 才開始,中文首屏會明顯閃一下無字型狀態。fonts.googleapis.com(style-src),字型檔來自 fonts.gstatic.com(font-src)。只放行一個會得到「樣式載入了但字型 404」的詭異狀態。到第 16 天做離線化時,我會把字型也搬到本地,順便把這兩條 CSP 例外拿掉。python3 -m http.server 8901
# 開 http://localhost:8901/index.html
檢查清單:頂欄 sticky、hero 三個統計格子(現在是「—」,Day 4 才會有數字)、視窗縮到 800px 以下版面不爆、DevTools console 無錯誤。
順手驗一下 token 紀律:
# 元件樣式裡不該有裸色碼——只允許 :root 出現 hex
grep -n '#[0-9a-fA-F]\{6\}' css/style.css | grep -v -A0 ':root'
今天的核心觀念只有一句:把設計決策集中成變數,把顏色下沉成資料。 前者讓 UI 一致,後者讓 JS 動態產生的 SVG 也能共用同一份色票。
明天寫第一個 Model:curriculum.js。整個網站的三頁都靠它,我會說明為什麼「課綱是資料而不是 HTML」是這個專案最重要的一個決定。
程式碼:
github.com/chunyichen0601/ithome-learning-web/tree/day02