今天要解的問題

Day22 把行前指南升成整頁的主角,頁首攤平成 地區|建議行程|備註|紀錄。改完隔天,我拿手機從頭到尾實際用了一輪,覺得 UIUX 仍舊不順阿 就一口氣丟給 Claude 這一串:

往上滑顯露,那個好卡,用一層就好了 行程推薦,將上面那塊拿掉,從日期選擇開始就好。然後如果是第一天或最後一天,在一開始的行程特別註明班機時間與航廈 地區下面的項目可以點擊後跳轉到地區的 電腦版與手機版的 header 選項都統一在最右邊 目前手機寬度很小的時候,會因為 header 日期太長而蓋到漢堡 Header 選項預設開啟是在建議行程,選項排序也優先以建議行程來當作第一個 總覽不是重要的,因為在當次旅遊只會注意當次 每個頁面的最上面不需要那些文字

第一條是我用手機滑一直會卡住,讓我氣噗噗。之前規劃了很多,想說這樣分兩層,一定會很棒,結果沒想到中間的那個階段一直沒有,反而感覺像 BUG 一樣。

因此決定直接拿掉,然後把固定列變矮。這篇記錄的就是這一輪 UI 調整:拿掉收起之後要固定哪一層、每個分頁頂端留什麼,以及後面陸續補上的地區篩選、備註標題、切換日期的捲動位置。

左邊是 Day21 的三種狀態:在頂端時三層固定列全部顯示(外層分頁列、地區/行程推薦/備註、日期列),往下滑全部收起,往上滑一點只露出日期列;右邊是現在:頁首加日期列固定一層,往下滑、往上滑都長一樣

想法與取捨

拿掉收起之後,固定哪一層

Claude 先問了四個問題,其中兩題的答案跟它的建議不一樣。第一題是「只要一層」要留哪一層:

  • 只固定頁首(它的建議):頁首一直在,日期按鈕、備註目錄跟著內容捲走
  • 頁首和日期列合成一層:日期按鈕和備註目錄黏在頁首下方,整塊不收起

我選了後者。Day21 往上滑一點先叫回的,也就是日期列這一層。後來計畫被我退回時又補了兩句,把範圍講清楚:

行程推薦頁面,請保留上面日期跳轉

第一個收起,請同時檢查每個頁面,因為像是備注那個頁面,也會有跟行程一樣的置頂

所以最後是逐頁決定,將每個頁面都會有置頂:建議行程是頁首+日期列,備註是頁首+目錄列,地區和紀錄只有頁首(地區的篩選列後來也變成固定,見下面)。

代價是這一層永遠佔著畫面。 Day21 選收起,就是不想讓導覽一直佔一塊。這次的補法是把這一層壓矮:拿掉頂端所有說明文字、日期列拿掉「全部」那顆按鈕、頁首和日期列的上下留白縮小。壓矮日期列這一步後來出了事,見「踩到的坑」。

「← 總覽」:收進選單,還是整個拿掉

理論上旅途中只會看當次。,其他都是雜訊。

每個分頁的頂端:簡化內容,移除統一說明

「每個頁面的最上面不需要那些文字」指的是指南頂端那四行:去程、回程、住宿、移動。班機我要的是在第一天和最後一天的行程最上面看到(清單第二條),所以改寫在那兩天;住宿本來就在每天的標題旁邊。

跟著一起拿掉的還有:地區分頁的示意圖說明句、建議行程的「選擇日期」小字、日期按鈕下方的當日摘要句(例如「西面 · 住 西面」,跟每天的標題區重複)。紀錄頁上方那行 facts 小字也拿掉,欄位本身從資料格式裡移除。

計畫被退回六次

這輪是先寫計畫、我看過才動手。計畫前後被我退回六次,每次補一點:

我退回時說的話計畫補了什麼
這塊在每個地方都不要有我沒講清楚是哪塊,Claude 列四個選項回問;答案是頂端那四行,模板也拿掉
請將我提供的項目做成清單,到時候結束我要來覆核計畫最前面加一張需求對照表,完工時逐項打勾
請同時檢查每個頁面…「固定一層」從只談建議行程,改成四個分頁逐頁列出
請保留上面日期跳轉明寫日期按鈕保留,只拿掉按鈕上下的小字
當日摘要句是什麼Claude 舉實例說明,再問要不要拿掉;我選拿掉
地區下面的單一景點介紹,也需要可以回到地區示意圖,讓我知道可以去哪每個景點多一顆按鈕,回到該區示意圖並亮起那個點

第二條「做成清單」後來很好用:完工回報直接是那張表,每一項標上做了什麼、上線了沒。只有「← 總覽」那項標著還在等,因為它要改 Worker,Worker 的改動要先經我看過才進 main。

地區篩選:要不要改成下拉選單

改完上線、在手機上用了一陣子,我又陸續提了幾項。其中「地區跟類別變成地區的置頂」先做成:篩選列固定在頂端,手機上收成一行摘要「地區 海雲臺 類別 吃・醬蟹 篩選 ▾」,點開才展開所有按鈕。

用了之後,我問 Claude:「地區與類別的篩選,你會建議要用下拉選單嗎?」它的回答是分開處理:

  • 地區改成下拉:一次只會選一個,總共 10 區,適合原生下拉選單。點下去跳出手機內建的選擇器,選完直接捲到那一區
  • 類別維持按鈕:可以同時選好幾個(例如「吃」加「推薦」),還有數量、★ 已標記,以及選「吃」才出現的子分類。原生下拉選單不能多選,也放不下這些資訊

之前原本有否決下拉選單,但那時候版面分類還不明確,現在將「建議行程」「地區」「備註」都分類好,因此可以選擇加入。

景點的兩顆按鈕:同一列,然後自成一行

「回到地區示意圖」那顆按鈕一開始叫「看位置 ↑」,跟「Google 地圖 ↗」並排在營業時間後面。手機上常常一顆在上一行、一顆在下一行,我先要求「同一列」,Claude 把兩顆包成一組不換行;用了之後我改成要它們「自成一行」,固定在營業時間、公休、價位那行的下面。按鈕文字最後覺得用「回地區」,會比較明確。

備註的段落標題、切換日期的位置

最後兩項是用的時候卡住的:

希望備註裡面的 出發前,旅途中,回程 的標題有明顯的區隔,現在容易滑過頭

我在建議行程選擇某一天後往下滑一點,切換到其他日期時,我希望能回到一開頭,目前是卡在中間

段落標題原本是一行灰色小字加一條細線,捲快一點就滑過去了。改成整條滿版的色帶,三段三種顏色,順便標出這一段有幾節。

手機寬度的兩個畫面。左邊是地區分頁:頂端固定一列,左邊是「地區」下拉選單(選了海雲臺),右邊是「類別 全部 篩選 ▾」按鈕;下面是海雲臺的店家卡片,每張卡片在營業時間那行下面自成一行放著「回地區」和「Google 地圖」兩顆按鈕。地區介紹文字已模糊處理。右邊是備註分頁:頂端是「目錄」列,往下捲到「旅途中」時,是一條滿版的深綠色色帶,寫著「旅途中 2 節」

實作

旅行頁分兩份文件:外層頁面(頁首、導覽、紀錄)和放在 iframe 裡的行前指南。每一項都改兩次:這一趟的頁面直接上線,模板 templates/trip.html、templates/guide.html 同步,之後新開的旅行都用新版。下面的片段取自模板。

拿掉收起,改成黏在頁首下方

Day21 的 docScrollShim 整段刪掉,外層頁面的 doc-away 樣式也一起刪:

/* 刪掉的部分(templates/trip.html) */
/* 手機上在內嵌文件裡往下滑時,頁首收起、文件往上補滿;往上滑再出現 */
.nav,.framepanel{transition:transform .2s ease}
.doc-away .nav,.doc-away .framepanel{transform:translateY(calc(-1 * var(--nav-h)))}
/* ... */

指南這邊只剩 sticky。日期列和備註目錄單獨打開時貼在指南自己的分頁列下面(top:45px);放進旅行頁時,指南的分頁列已經攤平到外層頁首,html.ts-tabs 讓它們貼齊 iframe 的頂端,看起來就跟頁首合成一層:

html.ts-tabs .daybar,html.ts-tabs .ntoc{top:0}
/* ... */
.daybar{position:sticky;top:45px;z-index:30;background:color-mix(in srgb,var(--paper) 92%,transparent);
  backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px);margin:0 -18px;padding:6px 18px;border-bottom:1px solid var(--rule)}

這條也寫進了 travel-storage 的 CLAUDE.md,以後開新旅行不會又被做回去:

- **固定一層、不隨捲動收起**:頁首固定在頂端;建議行程的日期列、備註的目錄貼在頁首正下方,和頁首合成一層,不做「往下收、往上露」
- **分頁頂端直接是內容**:不放說明文字、去程/回程/住宿摘要;班機寫在指南 `DAYS` 第一天/最後一天的 `flight`,畫在當天最上面
- 旅行頁不放「← 總覽」:旅途中只看當次

頁首:建議行程排第一,窄螢幕不蓋到漢堡

Day22 的頁首導覽是從指南的分頁按鈕攤平來的,順序照指南原本的「地區|建議行程|備註」。改成把指南預設選中的那一頁(aria-selected="true",也就是建議行程)拉到最前面:

const first = tabs.find(t => t.getAttribute('aria-selected') === 'true') || tabs[0];
FIRST_TAB[id] = first.id;
[first, ...tabs.filter(t => t !== first)].forEach(t => NAV.push({ p: id + ':' + t.id, label: t.textContent.trim(), doc: id, tab: t.id }));

選項移到最右邊,只是把 .tabs 搬到日期後面、加 margin-left:auto。很窄的手機另外處理:

/* 很窄的手機:漢堡只留 ☰,日期只留強調那一點的小字,避免日期蓋到漢堡 */
@media (max-width:420px){
  .nav .in{gap:6px}
  #curName{display:none}
  .menubtn{padding:0 12px}
  .dates .pt:not(.hot):not(.now) small{display:none}
}

漢堡按鈕平常寫著目前在哪一頁(#curName),420px 以下只留 ☰。時間軸上每個日期點下面有一行小字,窄的時候只留強調的那幾個:出發或回程當天(.hot),以及「還有 N 天」「第 N 天」那一點(.now)。:not(.now) 是 review 時被指出後才補的,第一版連倒數也一起藏掉了。

「← 總覽」:Worker 不注入,頁面裡再藏一次

「← 總覽」是 Worker 在回應時注入每一頁的,所以要從 Worker 拿掉:

// 旅行頁不放「← 總覽」:旅途中只看當次,總覽用不到
const homeLink = session.scope === 'site' && path !== '/' && !path.startsWith('/trips/');
return withSession(decorate(res, { homeLink }), env, session);

這一趟的頁面裡另外藏一次。旅行頁有離線快取,手機上可能拿到的是 Worker 改動之前的舊版,裡面還帶著那顆按鈕:

/* 旅行頁不放「← 總覽」:Worker 已不注入;這裡再藏一次,離線快取裡的舊版頁面也看不到 */
#ts-home{display:none!important}

班機寫在第一天和最後一天

DAYS 多一個選填欄位 flight,模板裡的範例:

{
  d:'1/1', w:'四', title:'抵達', stay:'地區一', areas:'地區一', flight:{label:'去程', no:'航班', dep:'桃園 T1 10:50', arr:'機場 14:00'},
  // ...
}

渲染時畫在當天標題正下方,緊接著是當天地區的連結列:

function flightLine(f){
  return `<div class="flight"><span class="fl">✈ ${esc(f.label)}</span><b class="mono">${esc(f.no)}</b>` +
    `<span class="fr">${esc(f.dep)} → ${esc(f.arr)}</span></div>`;
}
// ...
  ${d.flight ? flightLine(d.flight) : ''}
  ${d.areas ? areasLine(d.areas) : ''}

外層頁面原本的 TRIP.facts 也拿掉了,航班只有這一個地方寫,不會兩邊不同步。

地區:原生下拉選單

篩選列的地區按鈕換成 <select>,跟類別按鈕排在同一列:

<div class="frow">
  <label class="asel"><span>地區</span><select id="areasel" aria-label="選擇地區"></select></label>
  <button class="fsum" id="fsum" aria-expanded="false" aria-controls="fpanel"></button>
</div>
$('#areasel').innerHTML = '<option value="all">全部</option>' +
  AREAS.map(a => `<option value="${a.id}">${esc(a.short || a.name)}</option>`).join('');

function selectArea(id){
  curArea = AREAS.some(a => a.id === id) ? id : 'all';
  $('#areasel').value = curArea;
  document.querySelectorAll('#areas .area').forEach(a =>
    a.classList.toggle('away', curArea !== 'all' && a.id !== curArea));
  onFilterChange();
}
$('#areasel').addEventListener('change', e => {
  selectArea(e.target.value);
  closeFilter();
  // 選某一區就直接捲到那一區;「全部」回到最上面
  if (curArea === 'all') scrollTo({ top: 0 });
  // ...
});

selectArea 裡寫回 $('#areasel').value,是因為進到這一區的路不只下拉選單:從建議行程點地區名稱跳過來,也走 selectArea,選單要跟著顯示那一區。原本的摘要按鈕只剩類別,文字從「地區 X 類別 Y」縮成「類別 Y」。

回地區

每個景點的「回地區」打開該區的示意圖、捲過去,再亮起那個點:

const tomap = e.target.closest('.tomap');
if (tomap) {
  // 回到這一區的示意圖,亮起這個點
  const m = document.getElementById('it-' + tomap.dataset.map).closest('.area').querySelector('details.amap');
  if (m) { m.open = true; m.scrollIntoView({ block: 'start' }); }
  highlightSpot(tomap.dataset.map);
  return;
}

兩顆按鈕自成一行,只是讓 .acts 在 flex 換行的 .meta 裡佔滿一整列:

/* 「回地區」與「Google 地圖」自成一行 */
.item .meta .acts{display:flex;flex-basis:100%;gap:8px;margin-top:3px}

每顆「回地區」的文字都一樣,螢幕閱讀器分不出是哪家店,所以加上 aria-label="回到地區示意圖,看 ${esc(it.n)} 的位置"。這也是 review 時被指出的。

色帶標題、切換日期回開頭

段落標題換成滿版色帶,顏色用 data-phase 對應:

/* 出發前/旅途中/回程:整條色帶,捲動時一眼看出進到下一段 */
.nphase{margin:48px -18px 0;padding:14px 18px;font-family:"Noto Serif TC",serif;font-size:19px;font-weight:700;letter-spacing:.18em;
  color:#fff;background:var(--ph,var(--ink));display:flex;align-items:baseline;gap:10px}
.nphase[data-phase="出發前"]{--ph:var(--sea)}
.nphase[data-phase="旅途中"]{--ph:var(--k-shop)}
.nphase[data-phase="回程"]{--ph:var(--k-night)}

margin:48px -18px 0 的負邊距抵掉內容區左右的 18px 留白,色帶才會碰到螢幕兩邊。旁邊的「N 節」是渲染時數同一個 phase 的備註有幾則:

`<p class="nphase" data-phase="${esc(n.phase)}">${esc(n.phase)}<small>${NOTES.filter(x => x.phase === n.phase).length} 節</small></p>`

切換日期回到開頭只多一行:

$('#daybar').addEventListener('click', e => {
  const b = e.target.closest('.day');
  if (!b) return;
  selectDay(+b.dataset.day);
  scrollTo({ top: 0, behavior: 'instant' });   // 從中間換到別天時,回到那天的開頭
});

指南的 html 設了 scroll-behavior:smooth,這裡明寫 behavior: 'instant',直接跳到開頭,不走平滑捲動。

踩到的坑

壓低日期列,壓出 13px 的左右滑動

拿掉收起之後要把日期列壓矮,那一版把每顆日期按鈕的「日期」和「星期」從上下兩行改成同一行(例如「1/1 四」)。部署上去沒多久,我回了一句:

目前行程那一頁會有左右滑動的問題!!

Claude 把指南放進一個 360px 寬的 iframe,找出右緣超出畫面的元素:

{
  "dayScroll": 57,
  "dayW": 59,
  "iw": 360,
  "over": [
    "BUTTON.day r=373 1/6二",
    "SPAN.dw r=370 二"
  ],
  "sw": 373
}

(最後一顆按鈕的日期換成範例日期。)畫面寬 360,整頁的 scrollWidth 卻是 373:最後一顆日期按鈕的右緣在 373,超出 13px。一格只有 50 幾 px,放不下排成一行的日期加星期。

兩列日期按鈕的示意圖,都放在 360px 寬的手機畫面裡。上面是壓低日期列那版:六顆按鈕的日期和星期排成一行,最後一顆超出畫面右緣 13px,整頁可以左右滑。下面是修正後:日期和星期改回上下兩行,格線用 minmax(0,1fr),六顆剛好放進畫面。日期為範例

修法有兩層。日期和星期改回上下兩行,字和留白縮小,按鈕高度約 36px,還是比原本矮。另一層是讓格線不會被內容撐寬:

.days{display:grid;grid-template-columns:repeat(var(--n,7),minmax(0,1fr));gap:5px}
.day{appearance:none;border:1px solid var(--rule);background:var(--surface);color:var(--ink);border-radius:7px;
  padding:3px 1px;cursor:pointer;display:flex;flex-direction:column;align-items:center;line-height:1.15;font-family:inherit;min-width:0;overflow:hidden}

原本寫的是 repeat(n, 1fr)。1fr 的最小值是內容的最小寬度,內容比一格寬的時候,欄就被撐寬,整列跟著超出畫面。minmax(0,1fr) 把最小值壓到 0,按鈕再加 min-width:0 和 overflow:hidden,就算之後字變長,頂多被裁掉,不會把整頁撐寬。

把「不准左右滑」寫成硬規定

Day22 的規則檔已經寫了「不要任何左右滑動的列」,這次還是發生了,因為那條講的是「不要做出橫向捲動的列」,沒講到「內容不小心撐出畫面」。修完之後,我要求:

請把這個規定嚴格紀錄,不允許手機左右滑動

travel-storage 的 CLAUDE.md 改成兩處。一處是規定本身,連寫法都寫進去:

- **硬性規定:手機上任何分頁都不能左右滑動**。頁面寬度永遠不能超過螢幕,包括整頁與任何一列(不用 `overflow-x` 捲動列)。格線一律 `repeat(n, minmax(0,1fr))`、子元素加 `min-width:0`;不要用 `white-space:nowrap` 撐寬。改到版面就照「推送前」的寬度檢查做,沒通過不能推

另一處是推送前的檢查:

**改到版面(CSS、排列、表格、新元件)時,另外做寬度檢查**:把頁面(或取出的指南)放進 320/360/390/430px 寬的 iframe,建議行程、地區、備註、紀錄四個分頁都量 `document.documentElement.scrollWidth`,不得大於 iframe 寬度,並列出右緣超出畫面的元素(示意圖 SVG 內被裁掉的點不算)。任何一個寬度、任何一頁超出就不能推。

之後每次改版面,Claude 都在瀏覽器面板裡跑這段(截自它後來改地區下拉選單時跑的版本):

const html=document.documentElement.outerHTML; const res={};
for (const W of [320,360,390,430]) {
 const fr=document.createElement('iframe'); fr.style.cssText=`position:absolute;left:0;top:0;width:${W}px;height:800px;border:0`; document.body.appendChild(fr);
 fr.srcdoc='<!doctype html>'+html; await new Promise(r=>fr.onload=r); await new Promise(r=>setTimeout(r,400));
 const d=fr.contentDocument, w=fr.contentWindow; d.documentElement.classList.add('ts-tabs'); const t={};
 for (const id of ['t-plan','t-areas','t-notes']) { d.getElementById(id).click(); await new Promise(r=>setTimeout(r,80));
   const panel=d.getElementById(id.replace('t-','tab-'));
   t[id]=d.documentElement.scrollWidth+'/'+w.innerWidth+' '+[...panel.querySelectorAll('*')].filter(e=>!e.closest('svg')&&(r=>r.width>0&&r.right>w.innerWidth+1)(e.getBoundingClientRect())).slice(0,3).map(e=>e.tagName+'.'+e.className).join(',');
 }
 // ...
 res[W]=t; fr.remove();
}

把整份指南的 HTML 塞進四個不同寬度的 iframe,逐一點開三個分頁,記下 scrollWidth/innerWidth 和最多三個超出的元素。加上 ts-tabs 是模擬放在旅行頁裡的樣子。!e.closest('svg') 排除示意圖:地區圖上有幾個點本來就畫在圖框外,會被裁掉,不影響頁面寬度。修 13px 那次還沒排除,四種寬度的地區分頁都列出幾個 circle 和 text,就是這些被圖框裁掉的點。

紀錄頁在外層頁面,不在指南裡,這段量不到;規則上它也在四頁之內,這次的改動沒動到它。

「← 總覽」怎麼還在:自動部署停了

完工之後我打開手機,第一句是:

你總覽沒有刪掉!!

一開始的判斷是時間差:拿掉「← 總覽」要改 Worker,那部分才剛進 main;手機上也可能是離線快取裡的舊版。所以除了等 Worker,這趟頁面裡多加了前面那行 #ts-home{display:none!important}。

過幾分鐘我又截了一張圖,還是在。這時 Claude 去查部署紀錄:

Created:     2026-10-03T04:34:14.170Z
Created:     2026-10-03T07:30:02.171Z

wrangler deployments list 只抓 Created: 那幾行,最後一筆是台北時間 15:30,對應的是 15:17 推的版本。之後 16:10 到 16:25 推了七次,一次都沒上線,將近 20 分鐘,不像在排隊。travel-storage 的說明寫的是「push 到 main 約 1 分鐘自動上線」,那天已經不是這樣了。

我請 Claude 直接手動部署(這台電腦的 wrangler 已經登入),之後每次更新都是 push 完再跑一次 npm run deploy。自動部署為什麼停住,要到 Cloudflare 後台看建置紀錄;Claude 這邊的 Cloudflare 外掛還沒授權,看不到,這件事到現在還沒查。

小結

  • 固定一層:Day21 的「往下收、往上露」整個拿掉。建議行程是頁首+日期列,備註是頁首+目錄,地區是頁首+篩選列,紀錄只有頁首;頂端的說明文字全部拿掉,班機改寫在第一天和最後一天
  • 頁首:選項在最右邊,建議行程排第一、每次打開都停在它;420px 以下漢堡只留 ☰;旅行頁不再有「← 總覽」
  • 地區:地區改成原生下拉選單,類別維持按鈕;每個景點有「回地區」,跟「Google 地圖」自成一行
  • 備註與建議行程:三段標題是滿版色帶;切換日期回到那一天的開頭
  • 規則:「手機不能左右滑」寫成硬性規定,改版面前要在 320/360/390/430px 量過四個分頁

又往前邁進一步了呢,真是讚讚。下一步應該會是大幅的改動檔案結構,將原先的單檔設計給翻掉,不然目前滑動上一頁的跳轉都會是錯的。


本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day23-fixed-one-layer/