iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
AI Engineering

知識圖譜 : 技能樹式學習歷程系列 第 2

Day 2 — HTML 骨架與設計 token:CSS 變數就是你的設計系統

  • 分享至 

  • xImage
  •  

今天要解的問題

沒有 Tailwind、沒有 UI library,一切要自己寫。如果直接開始寫 .card { border-radius: 14px }.btn { border-radius: 14px }.chip { border-radius: 12px },兩週後這個網站會有五種圓角、七種灰色、三種陰影。

解法不是「小心一點」,是把設計決策集中成變數,讓不一致在物理上不可能發生。

三頁架構:為什麼不是 SPA

index.html    → 學習地圖(入口)
course.html   → 一門課的章節總覽
chapter.html  → 課文 + 測驗

三個真正的 HTML 檔,跨頁用超連結,不寫任何路由。理由:

  1. file:// 可直開是硬需求,SPA 路由在 file://history.pushState 會很尷尬。
  2. 這網站的導覽是「地圖 → 課程 → 課文」的樹狀下鑽,不是分頁切換,多頁式語意更正確。
  3. 少一層路由 = 少一個 bug 來源。真正需要「不重載」的地方只有同一章內換課,那用 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 限制,會直接讓雙擊開檔壞掉)。

設計 token::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

RWD:一個斷點就夠

@media (max-width: 880px) {
  .chapter-layout { grid-template-columns: 1fr; }   /* sidebar 收成單欄 */
  .nav-progress { display: none; }
}

我只設一個斷點 880px(課文頁 sidebar 收起來的臨界點)。不要一開始就鋪三四個斷點——那是在為還沒發生的問題寫程式。

踩到的雷

Google Fonts 的兩個坑。

  1. 沒有 <link rel="preconnect" href="https://fonts.googleapis.com"> 的話,字型請求要等 DNS + TLS 才開始,中文首屏會明顯閃一下無字型狀態。
  2. 更重要的:第 10 天要上嚴格 CSP 時,Google Fonts 需要同時放行兩個網域——CSS 來自 fonts.googleapis.comstyle-src),字型檔來自 fonts.gstatic.comfont-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


上一篇
Day 1 — 為什麼要自己刻一個學習網站?(開篇 + 技術選型)
系列文
知識圖譜 : 技能樹式學習歷程2
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言