今天要解的問題
kiwi-walk 是我用 Hugo 架的部落格,上面有三個系列:鐵人賽 30 天、鐵人賽之後的每日連載「Kiwi 的每日筆記」,以及挑過的 llm-wiki 閱讀清單。/series/ 是這三個系列的總覽,改版前長這樣:

我當時列了四個問題:
目前排版沒有明確的兩欄,所以導致後面的敘述有長有短 順序是 kiwi-daily, llm-wiki, 鐵人賽 需顯示目前最新文章是幾號 這個頁面有點無聊
第一個問題的原因在 CSS。這頁的卡片沿用文章列表的 .post-entry,而那個 class 在 custom.css 裡被設成 display: flex; flex-direction: row。標題、摘要、篇數三塊各自依內容寬度伸縮,標題長的那張摘要就被擠到右邊,三張卡的摘要起點都不一樣。順序則是 .Data.Terms.ByCount,依篇數排:鐵人賽 30 篇最多,所以排第一,可是它已經完結了,最該被看到的反而是還在更新的每日連載。
想法與取捨
這次我先要求「先讓我看畫面」,所以在動程式之前,所有方案都是用 HTML mockup 來回看的。下面每一輪都是看了畫面之後才做的決定。
一欄卡片,還是兩欄網格
第一版 mockup 只把卡片內部改成固定兩欄:左邊標題和摘要,右邊固定寬度放最新的 Day。這解決了摘要起點不齊的問題,但我想看的是「兩列的模板」,也就是卡片本身排成兩欄。
三個系列排兩欄,最後一列會剩一張。處理方式是讓第一張(還在連載的 Kiwi 的每日筆記)橫跨兩欄,另外兩張並排。這樣剛好也表達了優先順序:正在更新的系列最大。
標籤要幾個
兩欄版本裡我問了:「標籤除了基本的連載狀態,還有什麼標籤嗎?像是鐵人賽?」mockup 於是放了三層標籤:狀態(連載中/已完結)、類型(每日連載/精選清單/iThome 鐵人賽),再加上從文章自動抓出來的主題(engineer、books、llm-wiki……)。
看到畫面之後的回應很直接:
BAD 太多 tag,畫面太亂
所以三層合成一層,每張卡只留一個標籤,狀態和類型寫在同一個字串裡:「連載中」「精選」「鐵人賽 · 完結」。主題標籤整個拿掉,這個字串直接寫在 front matter,不從文章推算。
以日期為核心,但不要變成主角
接著我說「希望是以日期為核心」。日期怎麼呈現,當時有三個選項:全部系列都以日期為主、只有連載中的用日期、精選和完結都不顯示日期。我選了全部以日期為主:連載中顯示最新一篇的日期,精選顯示最後更新日,完結顯示起訖期間。
mockup 把日期做成卡片右上角的大字,結果回應是「日期的字太大,跟標籤大小一樣就好」。縮成 12px、和標籤同一行之後,又調了一輪位置:
- kiwi-daily 的日期放在最新文章的地方
- kiwi-daily 的 streak 請用 Day30+5 來表達,然後不需要寫幾篇,因為還沒有完結
- llm-wiki 的日期也是改放在最新
- 鐵人賽最終回,補上此篇的日期
最後的分工是(數字是改版當時的):右上角放「這個系列整體的狀態」,最底下一行放「最新一篇是哪天、哪篇」。
| 系列 | 右上角 | 最底行 |
|---|---|---|
| 每日筆記 | Day30+5 | 最新 10/19 + 標題 |
| LLM Wiki | 8 篇 | 最新 10/15 + 標題 |
| 鐵人賽 | 起訖日期 · 30 篇 | 最終回 10/14 + 標題 |
連載中的系列不寫篇數,因為它還沒完結,篇數只是一個會一直變的數字;Day30+5 則把「接著鐵人賽 30 天、又連續寫了 5 天」直接寫出來。
手機
mockup 定案、程式寫完之後,我補了一句「請同步考慮手機的閱讀」。600px 以下改成單欄;手機上的實際問題寫在後面的「踩到的坑」。
實作
卡片內容放在各系列的 _index.md
每個系列的 content/series/<term>/_index.md 多了幾個欄位,模板只讀這些,不寫死任何一個系列:
title: "Kiwi 的每日筆記"
summary: 鐵人賽結束後的每日連載:side project 的開發紀錄,也有從 llm-wiki 整理出來的看展、讀書與各種主題筆記。
# /series/ card (layouts/series/terms.html)
weight: 10
status: ongoing
badge: 連載中
streakBase: 30
accent: "#B86A2A"
accentDark: "#D08A4A"
weight 決定順序(10/20/30),badge 是卡片上唯一的標籤,status 決定右上角顯示什麼。streakBase: 30 是 Day30+N 裡的 30,放在 front matter 而不是模板,換一個連載也能用。accent 和 accentDark 是這張卡的顏色,深色模式用另一個比較亮的值。
模板:依 status 分三種
layouts/series/terms.html 把原本的 range .Data.Terms.ByCount 改成 range .Pages.ByWeight。在 taxonomy 的總覽頁上,.Pages 就是各個 term 頁,所以可以直接用 _index.md 的 weight 排序:
{{- range $i, $s := .Pages.ByWeight }}
{{- $posts := $s.Pages.ByDate }}
{{- with $posts }}
{{- $first := index . 0 }}
{{- $latest := index . (sub (len .) 1) }}
{{- $status := $s.Params.status }}
// ...
<article class="series-card{{ if eq $i 0 }} series-card--wide{{ end }}"{{ with $style }} style="{{ . | safeCSS }}"{{ end }}>
$s.Pages.ByDate 是這個系列的所有文章,由舊到新,第一篇和最後一篇分別給完結系列的起訖日期和「最新」那一行用。$i 是 0 的那張加上 series-card--wide,CSS 讓它橫跨兩欄。$style 是把 front matter 的 accent/accentDark 拼成 inline 的 CSS 變數。
右上角依 status 分三種:
<span class="series-meta">
{{- if eq $status "ongoing" }}
{{- with $latest.Params.day }}
{{- $base := $s.Params.streakBase | default 0 }}
{{- $n := int . }}
<span class="series-accent-text">Day{{ $base }}+{{ sub $n $base }}</span>
{{- end }}
{{- else if eq $status "finished" }}
<span class="series-accent-text">{{ $first.Date.Format "2006/1/2" }}–{{ $latest.Date.Format "1/2" }}</span> · {{ len $posts }} 篇
{{- else }}
{{ len $posts }} 篇
{{- end }}
</span>
連載中的 Day 讀最新一篇 front matter 的 day。這次改版剛做好時,Day 是用 findRE 從標題開頭的 DayN 抓的;後來標題拿掉 Day 編號,才改成讀 day 欄位,上面是現在的版本。最新一篇沒有 day 就整段不顯示,不會印出 Day30+。
最底那一行連到最新一篇:
<a class="series-latest" href="{{ $latest.Permalink }}">
<span class="series-accent-text">{{ if eq $status "finished" }}最終回{{ else }}最新{{ end }}</span>
<time datetime="{{ $latest.Date.Format "2006-01-02" }}">{{ $latest.Date.Format "1/2" }}</time>
<span class="series-latest-title">{{ replaceRE `^Day\d+\s*` "" $latest.Title }} →</span>
</a>
<a class="entry-link" aria-label="series link to {{ $s.Title | plainify }}" href="{{ $s.Permalink }}"></a>
replaceRE 是為了鐵人賽:那 30 篇的標題還是 Day30 回顧:… 的寫法,卡片上已經有「最終回」和日期,標題前面的 Day30 就去掉。
整張卡要能點進系列頁,最新文章那一行又要能單獨點進文章。卡片連結沿用 PaperMod 的 .entry-link:一個 position: absolute 蓋滿整張卡的空連結。最新文章那行要浮在它上面:
/* Sits above the card-wide .entry-link so the newest post is its own link. */
.series-latest {
position: relative;
z-index: 1;
display: flex;
gap: 8px;
margin-top: 14px;
padding-top: 10px;
border-top: 1px dashed var(--border-warm);
font-size: 14px;
color: var(--primary);
}
兩欄與顏色
兩欄用 grid,第一張橫跨,600px 以下改成單欄:
.series-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--gap);
}
.series-card--wide { grid-column: 1 / -1; }
@media (max-width: 600px) {
.series-grid { grid-template-columns: minmax(0, 1fr); }
.series-card { padding: 16px; }
.series-title { font-size: 18px; }
}
minmax(0, 1fr) 而不是 1fr,是因為 1fr 的最小寬度是內容寬度,一行很長的標題會把欄位撐開。
卡片本身是直向的 flex,摘要設 flex: 1 把剩下的高度吃掉,所以並排的兩張卡即使摘要長度不同,最底下的「最新」那一行還是對齊在卡片底部。
顏色只從 front matter 帶一個 accent 進來,標籤的淡底色用 color-mix 從它算出來,不另外再設一個欄位:
.series-card {
--series-accent-on: var(--series-accent, var(--secondary));
/* ... */
border-top: 4px solid var(--series-accent-on);
}
.dark .series-card {
--series-accent-on: var(--series-accent-dark, var(--series-accent, var(--secondary)));
}
.series-badge {
padding: 2px 10px;
border-radius: 999px;
color: var(--series-accent-on);
background: color-mix(in srgb, var(--series-accent-on) 16%, var(--card-surface));
}
--series-accent-on 是「這個模式下實際要用的 accent」:淺色用 accent,深色用 accentDark,沒設就退回網站的次要文字色。標籤、頂端色條、Day 和「最新」都讀這一個變數,所以切換深色模式只要換掉它。
踩到的坑
手機上,最新文章標題只剩幾個字
最新文章那一行一開始是單行加刪節號(white-space: nowrap 加 text-overflow: ellipsis)。桌面版兩欄還放得下大半個標題,但在 375px 寬的手機上,左邊已經有「最新」和日期,剩下的寬度只顯示得出「《人生就是冰淇淋,要盡快享用啊…」,後面的內容句和箭頭全部被切掉,讀者看不出這篇在講什麼。
改成最多兩行:
/* Two lines, not one: on a phone a single line cut the title to a few words. */
.series-latest-title {
min-width: 0;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
}

小結
/series/ 現在是兩欄卡片:正在連載的系列在最上面、橫跨兩欄,每張卡只有一個標籤,右上角是系列狀態(Day30+N、篇數或起訖日期),最底下一行是最新一篇的日期和標題。每張卡的內容全部由各系列 _index.md 的 front matter 決定,新增系列不用改模板。
版面上的每個決定,標籤只留一個、日期縮小、日期搬到最新文章那一行,都是看過 mockup 之後才定的;程式改動落在一個模板、一段 CSS 和四個 _index.md。

