今天要解的問題
Day19 把旅行頁的模板做好了,今天開始往下一趟還沒出發的旅行裡塞東西,也開始真的拿手機來看。兩個問題很快就冒出來:
- 手機上滑行前指南時,頂部的固定列太多,內容只剩一小塊
- 地點總表的「吃」已經有三十多家店,全部只標一個「吃」字,要找某一種吃的只能整份往下捲
- 本機的開發伺服器打不開
前兩件是今天的兩條主線,各是一個 session。第 3 件是做第 2 件時,我順口一句「我也看不到 localhost 的 server」帶出來的,在「踩到的坑」裡寫。
這一趟還沒出發。照這個專案自己的規矩,還沒結束的旅行不寫目的地,所以這篇不寫店名和地區,那四種菜也只說「四種」,截圖裡的行程文字也都模糊處理。
想法與取捨
頂部固定列:往下滑收起,還是只壓縮高度
我對 Claude 說的是:「用手機在滑行程的時候,上面有太多的置底,導致內容變少。」(我說的「置底」其實是置頂。)
Claude 先量了畫面,確認我說的是哪一頁:在行前指南的「行程推薦」裡,頂部疊了三層固定列。寫這篇時我用 375×812 的手機尺寸重新量了一次:
| 固定列 | 高度 |
|---|---|
| 外層頁面的分頁列 | 43px |
| 指南裡的「地區/行程推薦/備註」 | 50px |
| 指南裡的日期選擇列(標題加兩行日期按鈕) | 97px |
加起來 190px,整個畫面的四分之一左右,而且三層都是導覽,沒有一層是內容。

它給了三個做法:
- 往下滑時收起、往上滑再出現:內容區最大,但外層頁面和指南兩邊都要改
- 只壓縮高度、一直固定:指南的分頁不再固定,日期列改成單行,固定區從約 185px 降到約 90px,只改指南
- 兩者都做:改動最多
它推薦第一個,我也選了第一個。
往上滑:一次全部回來,還是分兩段
第一版是往上滑一點,三層就全部回來。用了之後我又提了一個想法:「可以分兩層嗎,較小的往上滑只會露出日期,較大的則會全部顯示。」
Claude 把它做成三種狀態:
| 動作 | 頂部顯示 |
|---|---|
| 往下滑 | 全部收起 |
| 往上滑一點(超過 8px) | 只露出日期列,貼在最上面 |
| 往上累積滑超過 160px,或回到頂部 | 三層全部回來 |
8px 沿用第一版判斷方向的門檻。160px 是 Claude 先抓的數字,它也說了:實際用起來太容易或太難叫出全部,再調大或調小。
程式裡把這一段寫成「文件只露出最常用的一列(例如日期)」。哪一列算最常用,由指南自己的 CSS 決定,外層只負責回報現在是哪個狀態。
行前指南說好原樣保留,這次要不要改
Day19 定過一條規則:行前指南內嵌之後原樣保留。但三層裡有兩層在指南自己的頁面裡,不動指南的話,外層只能收起自己那一層,約省 45px。
Claude 把這件事當成一個問題問我,而不是直接改。我答應改,範圍限定在樣式與捲動行為,內容一個字都不動。實際上指南只多了兩行 CSS,取出與放回都用 Day19 寫的那支內嵌文件工具。
內容與導覽怎麼分畫面:這次學到的幾件事
手機的畫面就那麼大,導覽(切地區、切日期)和內容(當天的行程)搶的是同一塊。這件事我覺得很重要,但我也還在學,下面是從這次的做法裡整理出來的幾點。
兩條路的差別,在於誰讓步。 壓縮高度是讓導覽一直在,代價是它永遠佔著一塊,只是小一點;收起是讓內容優先,導覽要用手勢叫回來,代價是要換日期時得先往上滑一下。這次選的是後者。
收起之後要很好叫回來,而且先叫回最常用的那一列。 不用捲回頂端,往上滑一點就露出日期列;真的要切分頁,再多滑一段,三層一起回來。這是我自己提的改法:導覽不是只有「全部」和「沒有」兩種。
有幾種時候一定要顯示:
- 離頂端 80px 以內:剛打開、或捲回最上面時,導覽一定在
- 切換分頁時:從「行程紀錄」切回「行前指南」,分頁列一定在
- 桌機寬度:只有 760px 以下才會收起,大螢幕維持原本的樣子
不要被誤觸發。 位移不到 8px 不判斷方向;捲到底時的回彈不算往上滑(這條第一版寫錯了,見「踩到的坑」)。
收起之後,內容要真的變大。 只把固定列往上移,底下會空出一條;所以收起時 iframe 立刻撐到整個畫面高,回來時才縮回去。
動畫要短,也要能關掉。 收起與出現是 0.2 秒的滑動;系統開了「減少動態效果」就不做動畫,直接切換。
看不見的東西,鍵盤也不該碰得到。 固定列只用 transform 移出畫面,裡面的按鈕還留在 Tab 順序和無障礙樹裡,用鍵盤或螢幕閱讀器的人會停在看不見的按鈕上。這點是 Copilot 在 review 裡指出的(見「踩到的坑」)。
導覽分散在兩份文件裡,就得讓它們說話。 外層的分頁列和指南裡的兩層不在同一個頁面,要一起收起,只能靠 iframe 回報捲動方向(見「實作」)。
壓縮那條路這次沒有走,日期列出現時還是兩行、97px。
子分類放在哪:選填的 sub,只給「吃」用
我的要求只有一句:「請將吃的類型,再往下分一類」。
Claude 沒有動原本的 kind(美術館、咖啡、吃、買、景點、夜間、其他),而是在地點上多一個選填欄位 sub,只對 kind:'food' 有效。第一輪分了七種:湯品、海鮮、小吃、市場、烤肉、麵、炸雞披薩。沒填 sub 的店照舊只顯示「吃」,所以舊頁面和還沒分類的店都不受影響。
規則同時寫進三個地方:模板的資料區註解、給 Claude 讀的規則檔,以及旅途中找附近店家的 /nearby 指令。之後臨時加的店,寫進頁面時就會帶上子分類。
右側的標籤寫成「吃・湯品」這種格式。一開始湯品的中文名是「湯飯・湯」,標籤變成「吃・湯飯・湯」,中間兩個點不好讀,Claude 自己在瀏覽器裡看到之後改短成「湯品」,「炸雞・披薩」也改成「炸雞披薩」。
有幾家店的歸類比較主觀,Claude 列出來讓我決定:兩個以海鮮為主的市場放進「海鮮」而不是「市場」;一家坐下來吃的店暫時放在「小吃」。
特別想吃的四種:拉成篩選按鈕
分完之後,我又補了一句:有幾種希望特別拉出來,給了四個名字,「如果該選項太少,請幫我網路上搜尋」。
Claude 的做法是在地點總表上方加一排按鈕,每顆標著店家數,點一下只看那一類,再點一次取消。這四種在資料上一樣是 sub 的值,其中一種(烤肉)原本就有,另外三種是新增的;原本歸在「湯品」的其中五家,改到其中一個新類別。
頁面上沒有某一類的店,那顆按鈕就不出現。 模板是所有旅行共用的,沒有用到子分類的頁面,就不會多出一排空按鈕。
選項太少:讓 AI 上網補,還是我丟資料給它統整
四種裡有兩種原本是 0 家。Claude 上網查了之後補進頁面,每一家都標上「網路找」,跟我自己挑的店分開。
查不到的地方它沒有填一個看起來合理的值:
- 一家主打別的料理的店,網路上說有這道菜,但菜單上查不到,備註寫「菜色未確認」,問我要不要留
- 一家查不到營業時間,標「未確認」
這跟 Day19 定的 /nearby 規則是同一條:查不到營業時間的要明講,不要編。
選項太少讓它補、店太多讓它分類篩選,這就是我用 AI 當旅遊輔助的好處。
但我其實不知道它的 source 來自哪裡,這可能會是很大的問題。所以我比較常用的方式是反過來:在別處看到有趣的消息,就丟給 Claude,叫它幫我加進去,由它來統整。 這次後來加的兩家烤肉就是這樣來的,我只給了店名,其中一家附上地區;營業時間、位置、怎麼排隊是它去查、寫進頁面的。
頁面上看得出這兩種來源的差別:它自己上網找的標「網路找」;我指定要加的,它標成「推薦」。
實作
手機上往下滑,三層一起收起
行前指南是放在 iframe 裡的另一份文件,捲動發生在 iframe 裡面,外層頁面收不到。所以做法是在 iframe 裡注入一小段程式回報捲動方向。外層頁面原本就有一段注入 iframe 的程式(docStorageShim,把指南的 localStorage 轉存到外層),新的 docScrollShim 跟它放在一起:
// 手機上依捲動方向收起固定列,同樣會被轉成字串放進 iframe 執行:
// 往下滑 → 文件 <html> 加 ts-away:全部收起
// 往上滑一點 → 改成 ts-peek:文件只露出最常用的一列(例如日期)
// 往上滑超過 160px、或回到頂部 → 全部顯示
// 文件自己決定 ts-away/ts-peek 要收起哪些列;本頁的分頁列在兩種狀態下都收起。
function docScrollShim(id){
const narrow = matchMedia('(max-width:760px)');
const PEEK = 8, FULL = 160;
let last = 0, up = 0, state = '';
const set = v => {
if (v === state) return;
state = v;
const c = document.documentElement.classList;
c.toggle('ts-away', v === 'away');
c.toggle('ts-peek', v === 'peek');
parent.postMessage({ type: 'ts-doc-scroll', doc: id, away: v !== '' }, '*');
};
addEventListener('scroll', () => {
const y = scrollY, el = document.documentElement;
if (!narrow.matches || y < 80) { last = y; up = 0; set(''); return; }
if (y < last && y + innerHeight >= el.scrollHeight - 2) { last = y; return; } // 捲到底的回彈不算往上滑
const d = y - last;
if (Math.abs(d) < PEEK) return;
last = y;
if (d > 0) { up = 0; set('away'); }
else if (state !== '') { up -= d; set(up >= FULL ? '' : 'peek'); }
}, { passive: true });
narrow.addEventListener('change', () => { if (!narrow.matches) set(''); });
}
這是兩段式之後的版本。只在 760px 以下作用;離頂端 80px 內一律顯示;移動不到 8px 不判斷方向。往下滑就全部收起,並把往上累積的距離 up 歸零;往上滑時把距離累加起來,不到 160px 是 ts-peek,超過就全部顯示。
狀態改變時做兩件事:在指南自己的 <html> 加上 ts-away 或 ts-peek,讓指南決定收起哪幾層;再用 postMessage 通知外層。外層的分頁列只分「收起」和「顯示」,ts-peek 時一樣收起,所以送出去的只有 away: v !== ''。
外層收到通知時,先確認訊息真的來自目前顯示的那個 iframe,再切換 doc-away:
addEventListener('message', e => {
const m = e.data;
if (!m || m.type !== 'ts-doc-scroll' || typeof m.doc !== 'string' || !DOCS[m.doc]) return;
const panel = document.getElementById('p-' + m.doc);
const frame = panel && panel.querySelector('iframe');
if (!frame || panel.hidden || e.source !== frame.contentWindow) return;
document.body.classList.toggle('doc-away', m.away === true);
});
切換分頁時(show())也會移除 doc-away,切回來時分頁列一定在。
外層的 CSS:
/* 手機上在內嵌文件裡往下滑時,分頁列收起、文件往上補滿;往上滑再出現 */
.nav,.framepanel{transition:transform .2s ease}
.doc-away .nav,.doc-away .framepanel{transform:translateY(-45px)}
/* 收起時用 visibility 拿掉分頁按鈕的鍵盤焦點與無障礙樹;等滑出動畫結束才隱藏,出現時立即恢復 */
.doc-away .nav{visibility:hidden;transition:transform .2s ease,visibility 0s .2s}
.framepanel iframe{transition:height 0s .2s}
.doc-away .framepanel iframe{height:100dvh;transition:none}
@media (prefers-reduced-motion:reduce){.nav,.framepanel,.framepanel iframe,.doc-away .nav{transition:none}}
分頁列和 iframe 一起往上移 45px。iframe 原本的高度是「整個畫面減 45px」,往上移之後底部會空出一條,所以收起時立刻撐到整個畫面高;恢復時延遲 0.2 秒、等滑回來之後才縮回去。
指南那邊的樣式:
/* 外層頁面在手機上捲動時:ts-away 分頁與日期列都收起;ts-peek 只露出日期列(往上貼齊頂端) */
.tabs,.daybar{transition:transform .2s ease}
html.ts-away .tabs,html.ts-away .daybar,html.ts-peek .tabs{transform:translateY(calc(-100% - 45px))}
html.ts-peek .daybar{transform:translateY(-45px)}
/* 收起的列用 visibility 移出鍵盤焦點與無障礙樹;等滑出動畫結束才隱藏,出現時立即恢復 */
html.ts-away .tabs,html.ts-away .daybar,html.ts-peek .tabs{visibility:hidden;transition:transform .2s ease,visibility 0s .2s}
ts-peek 時,分頁那一列照樣收起,日期列只往上移 45px,剛好貼到分頁原本的位置,也就是畫面最上面。ts-away 和 ts-peek 只有外層注入的程式會加,單獨打開指南時不受影響。
渲染:多一個篩選狀態 sf
下面的片段省略了子分類的代號與中文名(會看出這趟去哪),以 /* ... */ 標示:
const KIND = {museum:'美術館', cafe:'咖啡', food:'吃', shop:'買', sight:'景點', night:'夜間', other:'其他'};
const SUB = {/* ... 十種子分類:代號 → 中文名 */};
// 特別拉出來的種類;頁面上沒有的不顯示按鈕
const SUB_PICK = [/* ... 四個代號 */];
let pf = TODAY > TRIP.end ? 'yes' : 'all';
let sf = '';
function renderPlaces(){
document.querySelectorAll('#pfilt button').forEach(x => x.setAttribute('aria-pressed', x.dataset.f === pf));
const picks = SUB_PICK.filter(k => PLACES.some(p => p.sub === k));
const sfilt = document.getElementById('sfilt');
sfilt.hidden = !picks.length;
sfilt.innerHTML = picks.map(k => `<button data-s="${k}" aria-pressed="${sf===k}">${SUB[k]} <span class="mono">${PLACES.filter(p => p.sub===k).length}</span></button>`).join('');
const list = PLACES.filter(p => (pf==='all' ? true : pf==='yes' ? p.visited===true : p.visited!==true) && (!sf || p.sub===sf));
// ...
}
document.getElementById('sfilt').addEventListener('click', e => {
const b = e.target.closest('button'); if(!b) return;
sf = sf === b.dataset.s ? '' : b.dataset.s;
renderPlaces();
});
原本就有一排「全部/去過/還沒去」的按鈕(pf),新的子分類按鈕(sf)跟它疊加:兩個條件都符合的店才會列出來。picks 只留頁面上真的有店的種類,一個都沒有就把整排藏起來。點同一顆按鈕兩次,sf 回到空字串,等於取消篩選。
每家店右側的標籤,有子分類就寫成「吃・子分類」:
<span class="k">${SUB[p.sub] ? '吃・'+SUB[p.sub] : KIND[p.kind]||''}</span>
檢查腳本擋掉打錯的 sub
const FOOD_SUBS = new Set([/* ... 同上十個代號 */]);
// ...
if (p?.sub !== undefined && (p?.kind !== 'food' || !FOOD_SUBS.has(p.sub))) fail(at, `sub 只用在 kind:'food',必須是 ${[...FOOD_SUBS].join('|')},目前是 ${JSON.stringify(p?.sub)}`);
兩種錯誤都會擋:代號拼錯,以及把 sub 放在不是吃的地點上。拼錯的代號在畫面上不會報錯,那家店只會顯示成「吃」,按任何一顆子分類按鈕都找不到它。
驗證
在本機的開發伺服器上,用 375×812 的手機尺寸開行前指南:往下滑三層全部收起,內容填滿整個畫面;往上滑一點就全部回來。桌機寬度的行為跟原本一樣。改成兩段之後再測一次:上滑一格只出現日期列,再滑兩格三層都回來。
加上 visibility 那一輪,瀏覽器面板的捲動事件傳不進沙箱 iframe。Claude 改用同一份指南內容放進測試用的 iframe,用程式捲動,逐一檢查三種狀態下哪些按鈕還能被聚焦:全部收起時都不行,只露日期時日期按鈕可以、分頁不行,全部顯示時都可以。它也直接說了:這一輪沒有在手機尺寸下實際捲動看畫面。寫這篇時我用程式捲動截了上面那三張圖,三種狀態都對得上。
地點總表那邊,同樣在本機打開那一頁:第一輪分完,每個子分類顯示的數量跟 Claude 列的表一致;四顆按鈕各點一次,點完只剩那一類的店。後來我又指定加兩家,烤肉那顆從 4 家變成 6 家。推送前的檢查腳本全部通過。
踩到的坑
防回彈的條件,把正常往下滑也擋掉了
iPhone 捲到底時會回彈,回彈的那一下 scrollY 會變小,看起來像「往上滑」,固定列就會跑出來。計畫裡寫了一條「捲到底部時忽略」,第一版是這樣:
if (y + innerHeight >= el.scrollHeight - 2) { last = y; return; } // 捲到底的回彈不算往上滑
在手機尺寸實測時,Claude 選了很短的一天,一口氣捲到底,固定列沒有收起來。那一天短到一捲就到底,捲動事件落在「已經在底部」的條件裡被直接忽略,連往下滑都沒判斷到。
回彈只會造成往上的誤判,所以只要忽略「在底部而且往上」的那一下:
if (y < last && y + innerHeight >= el.scrollHeight - 2) { last = y; return; } // 捲到底的回彈不算往上滑
改完之後,短的那一天往下滑也會收起。要用很短的內容測過一次,才看得出「在底部」這個條件其實太寬。
收起的列,鍵盤還 Tab 得到
這個坑不是我們自己踩到的,是 Copilot 在 PR 上的 review 指出來的:
這些列只是用
transform移出畫面,裡面的分頁按鈕仍留在鍵盤焦點順序與無障礙樹中;ts-peek時使用者會 Tab 到看不見的控制項。
Claude 同意,也發現外層的分頁列有同樣的問題,一起修。做法是收起時加上 visibility:hidden,按鈕就離開 Tab 順序和無障礙樹。細節在轉場:visibility 延遲 0.2 秒才生效,等滑出的動畫跑完才隱藏;出現時沒有延遲,按鈕一回到畫面就能點。
改的時候還碰到一個優先權的問題:新的 .doc-away .nav 帶了自己的 transition,比原本「減少動態效果」那條 @media 裡的 .nav 規則優先權高,開了減少動態效果還是會有動畫。所以 @media 那條也要補上 .doc-away .nav。
只看畫面不會發現這件事。收起導覽時,要一起想「看不見」對鍵盤和螢幕閱讀器代表什麼。
hidden 被 display:flex 蓋掉
「沒有就不顯示按鈕」靠的是 sfilt.hidden = true。但地點總表的篩選列原本就有這條:
.filt{display:flex;gap:6px;flex-wrap:wrap;margin:0 0 12px}
hidden 屬性在瀏覽器的預設樣式裡只是 display:none,作者寫的 display:flex 優先權比它高,所以加了 hidden 一樣會顯示出來。這次是 Claude 改完、還沒開瀏覽器測就先發現了,補一行:
.filt[hidden]{display:none}
自己的 CSS 只要對某個元素設了 display,hidden 就不再可靠,要補一條 [hidden] 的規則。
localhost 在原地無限轉址
我在對話裡說看不到 localhost。Claude 用 curl 直接打本機的開發伺服器:
HTTP/1.1 301 Moved Permanently
Location: http://localhost:8787/
Cache-Control: no-store
301,而且是轉回同一個網址。
這個 Worker 開頭有一條規則:http:// 的請求一律 301 轉到 https://,本機開發除外,判斷方式是看網址的主機名稱是不是 localhost 這幾個。照理本機不會被轉。
問題在 wrangler:設定檔裡有正式的自訂網域,wrangler dev 會把傳進 Worker 的請求網址換成正式網域,協定則維持 http。Worker 看到的是「正式網域的明文連線」,不在本機的例外名單裡,於是轉到 https;wrangler 再把回應裡的 Location 改寫回 http://localhost:8787/。瀏覽器就這樣在原地一直轉。
這個問題在主分支上本來就有,不是這次改出來的。
修法只動設定檔,正式網站不受影響:
// 本機開發:wrangler dev 會把請求網址換成上面的自訂網域,協定預設是 http,
// Worker 會當成外部明文連線一直轉址到 https。讓本機請求以 https 傳進 Worker
"dev": { "upstream_protocol": "https" },
改完之後本機打開是鎖頁面,用測試密碼登入能拿到旅行頁。
小結
- 手機上的頂部固定列:在行前指南裡往下滑,三層一起收起;往上滑一點只露出日期列,滑超過 160px 或回到頂端才全部出現;收起的列鍵盤碰不到。模板也一起改,之後新建的旅行頁都有這個效果
- 「吃」的子分類:選填的
sub,第一輪分了七種,三十多家店都分好 - 四顆篩選按鈕:跟原本的去過/還沒去篩選疊加;頁面上沒有的種類不顯示;原本是 0 家的兩種由網路補上,標「網路找」,查不到的寫「未確認」
- 檢查腳本:擋掉拼錯的
sub,以及放在非吃類地點上的sub - 本機開發:
wrangler dev不再無限轉址
固定列分兩個 PR、子分類與本機開發一個 PR,都已經合併上線。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day21-food-subcategory/
