<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Ux on Tarragon</title><link>https://tarrragon.github.io/blog/tags/ux/</link><description>Recent content in Ux on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/ux/index.xml" rel="self" type="application/rss+xml"/><item><title>Human-in-the-loop（HITL）</title><link>https://tarrragon.github.io/blog/llm/knowledge-cards/human-in-the-loop/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/knowledge-cards/human-in-the-loop/</guid><description>&lt;p>Human-in-the-loop（HITL）的核心概念是「&lt;strong>人類在 LLM 工作流中介入的設計&lt;/strong>」、用來在 fuzzy AI 行為的關鍵節點插入 deterministic 人類判斷。HITL 不是「有 vs 沒有」的二元、是 spectrum：位置由 risk（副作用範圍 + 失敗代價）跟自動 validator 能力決定。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>HITL 三種觸發時機：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>時機&lt;/th>
 &lt;th>介入點&lt;/th>
 &lt;th>適合任務&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Pre-act&lt;/td>
 &lt;td>Action 執行前確認&lt;/td>
 &lt;td>不可逆 / 高代價（DB write、deploy）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mid-stream&lt;/td>
 &lt;td>Agent 過程中遇不確定主動問&lt;/td>
 &lt;td>路徑分歧、需要 domain judgment&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Post-hoc&lt;/td>
 &lt;td>結果交付後 user 申訴 / 校正&lt;/td>
 &lt;td>評分類、低代價、user 數量大&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>跟其他相關概念對照：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>概念&lt;/th>
 &lt;th>跟 HITL 的關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Agent 自主度分層&lt;/td>
 &lt;td>Full auto / checkpoint / step-by-step / plan-first → 對應 HITL 時機&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tool 副作用範圍&lt;/td>
 &lt;td>等級 1-2 不需 HITL、等級 4-5 強制 HITL&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Guardrail&lt;/td>
 &lt;td>Schema / validator / monitoring 是自動 guardrail、HITL 是人類 guardrail&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>讀 AI 應用設計或 agent paper 看到「HITL」「human-in-the-loop」「approval flow」「appeal」就是這個機制。實作判讀：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>位置由 risk 跟 validator 能力決定&lt;/strong>：risk 高 + validator 弱、HITL 頻率高；risk 低 + validator 強、HITL 頻率低。&lt;/li>
&lt;li>&lt;strong>三時機可組合&lt;/strong>：pre-act 擋高代價、mid-stream 處理 agent 不確定性、post-hoc 收回饋。三者各擋不同 risk class、不互斥。&lt;/li>
&lt;li>&lt;strong>避免橡皮圖章化的四條件&lt;/strong>：分級不同 risk 走不同 gate、approval UI 強制 show diff、reject 有明確 fallback、approval 訊號回饋進系統。任一不滿足、HITL 退化成形式。&lt;/li>
&lt;li>&lt;strong>跟 &lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/jagged-frontier/" data-link-title="Jagged frontier" data-link-desc="AI 能力分佈不規則的 framing：某些看似簡單的任務 AI 容易壞、某些看似複雜的任務 AI 反而做得好">jagged frontier&lt;/a> 的關係&lt;/strong>：frontier 外的任務該強制 HITL、不交給 user 自由心證。&lt;/li>
&lt;li>&lt;strong>跟 fuzzy engineering 典範的關係&lt;/strong>：HITL 是 fuzzy 行為的 deterministic guardrail 一種、不是預設要有、看 risk 跟自動 validator 能力決定。&lt;/li>
&lt;/ol>
&lt;p>完整 HITL 拓樸設計見 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/human-ai-collaboration/" data-link-title="4.5 人機協作拓樸：何時人介入、怎麼介入" data-link-desc="Centaur vs Cyborg 工作模式、jagged frontier、HITL 三種觸發時機（pre-act / mid-stream / post-hoc）、確認流程的設計避免橡皮圖章化">4.5 人機協作拓樸&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>Human-in-the-loop（HITL）的核心概念是「<strong>人類在 LLM 工作流中介入的設計</strong>」、用來在 fuzzy AI 行為的關鍵節點插入 deterministic 人類判斷。HITL 不是「有 vs 沒有」的二元、是 spectrum：位置由 risk（副作用範圍 + 失敗代價）跟自動 validator 能力決定。</p>
<h2 id="概念位置">概念位置</h2>
<p>HITL 三種觸發時機：</p>
<table>
  <thead>
      <tr>
          <th>時機</th>
          <th>介入點</th>
          <th>適合任務</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pre-act</td>
          <td>Action 執行前確認</td>
          <td>不可逆 / 高代價（DB write、deploy）</td>
      </tr>
      <tr>
          <td>Mid-stream</td>
          <td>Agent 過程中遇不確定主動問</td>
          <td>路徑分歧、需要 domain judgment</td>
      </tr>
      <tr>
          <td>Post-hoc</td>
          <td>結果交付後 user 申訴 / 校正</td>
          <td>評分類、低代價、user 數量大</td>
      </tr>
  </tbody>
</table>
<p>跟其他相關概念對照：</p>
<table>
  <thead>
      <tr>
          <th>概念</th>
          <th>跟 HITL 的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Agent 自主度分層</td>
          <td>Full auto / checkpoint / step-by-step / plan-first → 對應 HITL 時機</td>
      </tr>
      <tr>
          <td>Tool 副作用範圍</td>
          <td>等級 1-2 不需 HITL、等級 4-5 強制 HITL</td>
      </tr>
      <tr>
          <td>Guardrail</td>
          <td>Schema / validator / monitoring 是自動 guardrail、HITL 是人類 guardrail</td>
      </tr>
  </tbody>
</table>
<h2 id="設計責任">設計責任</h2>
<p>讀 AI 應用設計或 agent paper 看到「HITL」「human-in-the-loop」「approval flow」「appeal」就是這個機制。實作判讀：</p>
<ol>
<li><strong>位置由 risk 跟 validator 能力決定</strong>：risk 高 + validator 弱、HITL 頻率高；risk 低 + validator 強、HITL 頻率低。</li>
<li><strong>三時機可組合</strong>：pre-act 擋高代價、mid-stream 處理 agent 不確定性、post-hoc 收回饋。三者各擋不同 risk class、不互斥。</li>
<li><strong>避免橡皮圖章化的四條件</strong>：分級不同 risk 走不同 gate、approval UI 強制 show diff、reject 有明確 fallback、approval 訊號回饋進系統。任一不滿足、HITL 退化成形式。</li>
<li><strong>跟 <a href="/blog/llm/knowledge-cards/jagged-frontier/" data-link-title="Jagged frontier" data-link-desc="AI 能力分佈不規則的 framing：某些看似簡單的任務 AI 容易壞、某些看似複雜的任務 AI 反而做得好">jagged frontier</a> 的關係</strong>：frontier 外的任務該強制 HITL、不交給 user 自由心證。</li>
<li><strong>跟 fuzzy engineering 典範的關係</strong>：HITL 是 fuzzy 行為的 deterministic guardrail 一種、不是預設要有、看 risk 跟自動 validator 能力決定。</li>
</ol>
<p>完整 HITL 拓樸設計見 <a href="/blog/llm/04-applications/human-ai-collaboration/" data-link-title="4.5 人機協作拓樸：何時人介入、怎麼介入" data-link-desc="Centaur vs Cyborg 工作模式、jagged frontier、HITL 三種觸發時機（pre-act / mid-stream / post-hoc）、確認流程的設計避免橡皮圖章化">4.5 人機協作拓樸</a>。</p>
]]></content:encoded></item><item><title>4.5 人機協作拓樸：何時人介入、怎麼介入</title><link>https://tarrragon.github.io/blog/llm/04-applications/human-ai-collaboration/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/llm/04-applications/human-ai-collaboration/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">HITL（human-in-the-loop）&lt;/a> 設計的本質是&lt;strong>在「人類介入頻率」spectrum 上選位置&lt;/strong>——位置由 risk（副作用範圍 + 失敗代價）跟自動 validator 能力決定。risk 高 + validator 弱、人類介入頻率高；risk 低 + validator 強、人類介入頻率低。落點選錯就會出兩種事故：自動化過度跑 production migration 是 over-trust、每個 tool call 都要 approval 是 under-trust。&lt;/p>
&lt;p>本章寫人機協作的拓樸設計：兩種工作模式（centaur / cyborg）、能力邊界的不規則性（jagged frontier）、三種 HITL 觸發時機、跟 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 自主度分層&lt;/a> 的對應。這層問題是跨產品 / 跨領域通用、跟具體 framework 無關。&lt;/p>
&lt;h2 id="本章目標">本章目標&lt;/h2>
&lt;p>讀完本章後你能：&lt;/p>
&lt;ol>
&lt;li>區分 centaur 跟 cyborg 兩種工作模式、判斷哪種適合哪種任務。&lt;/li>
&lt;li>描述 jagged frontier、解釋為什麼「全自動」是錯題。&lt;/li>
&lt;li>在 pre-act / mid-stream / post-hoc 三個時機點選對 HITL 設計。&lt;/li>
&lt;li>設計確認流程、避免人類變橡皮圖章。&lt;/li>
&lt;li>把這層設計對應回 &lt;a href="https://tarrragon.github.io/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構&lt;/a> 的自主度分層。&lt;/li>
&lt;/ol>
&lt;h2 id="兩種工作模式centaur-跟-cyborg">兩種工作模式：Centaur 跟 Cyborg&lt;/h2>
&lt;p>Centaur 跟 cyborg 是兩種人類跟 LLM 共事的姿態。概念起源於 Kasparov 2010 提的 advanced chess（人類 + AI 配合下棋）、HBS / UPenn / Wharton 對 BCG 顧問使用 AI 的研究把這對 framing 套到 knowledge work、觀察到兩種使用模式都存在且各有適用。&lt;/p>
&lt;h3 id="centaur-模式">Centaur 模式&lt;/h3>
&lt;p>人類把整段任務委派給 LLM、等結果回來再審。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>比喻&lt;/strong>：人馬獸——上半身人、下半身馬、清楚的職責分工。&lt;/li>
&lt;li>&lt;strong>典型場景&lt;/strong>：「寫一份這個主題的 PPT 大綱、含三個案例、按以下風格、做完給我」、LLM 跑幾分鐘、人類審結果。&lt;/li>
&lt;li>&lt;strong>適合&lt;/strong>：任務邊界清楚、人類能事先描述完整需求、結果可離線審。&lt;/li>
&lt;li>&lt;strong>失敗模式&lt;/strong>：任務描述漏細節、LLM 跑偏到沒注意、結果不能用。緩解：先給小範圍試跑、確認方向再放手。&lt;/li>
&lt;/ul>
&lt;h3 id="cyborg-模式">Cyborg 模式&lt;/h3>
&lt;p>人類跟 LLM 緊密協作、快速來回、人類隨時調整方向。&lt;/p>
&lt;ul>
&lt;li>&lt;strong>比喻&lt;/strong>：半機械人——人類跟 LLM 融合、邊做邊改。&lt;/li>
&lt;li>&lt;strong>典型場景&lt;/strong>：寫 code 時 IDE 內 inline completion、寫文章時邊輸入邊看 LLM 建議、debug 時來回問。&lt;/li>
&lt;li>&lt;strong>適合&lt;/strong>：任務探索性、需求邊做邊浮現、無法事先完整描述。&lt;/li>
&lt;li>&lt;strong>失敗模式&lt;/strong>：頻繁打斷思路、context switch 成本高、最後產出反而比 centaur 慢。緩解：對熟悉的任務 cyborg、不熟的任務 centaur。&lt;/li>
&lt;/ul>
&lt;h3 id="該用哪種">該用哪種&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>任務性質&lt;/th>
 &lt;th>預設模式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>邊界清楚、需求可事先描述完整&lt;/td>
 &lt;td>Centaur&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>探索性、邊做邊定義&lt;/td>
 &lt;td>Cyborg&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>大量重複（如 100 篇文章）&lt;/td>
 &lt;td>Centaur&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>創意 / 設計、要看回饋微調&lt;/td>
 &lt;td>Cyborg&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高代價、要 rollback 控制&lt;/td>
 &lt;td>Centaur + 強 review&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>學生 / 個人開發更常 cyborg 工作、企業自動化更常 centaur 工作。看到一個產品設計時、問「它鼓勵 user 走 centaur 還是 cyborg」、就能判讀它的設計取向。&lt;/p>
&lt;h2 id="jagged-frontierai-能力的不規則邊界">Jagged Frontier：AI 能力的不規則邊界&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/llm/knowledge-cards/jagged-frontier/" data-link-title="Jagged frontier" data-link-desc="AI 能力分佈不規則的 framing：某些看似簡單的任務 AI 容易壞、某些看似複雜的任務 AI 反而做得好">Jagged frontier&lt;/a> 是觀察 AI 能力分佈的 framing。直覺上「AI 能做的任務」應該是一個 smooth 的連續區、簡單的能做、難的不能。實際上不是——AI 能做的任務分佈是&lt;strong>鋸齒狀（jagged）&lt;/strong>：某些看起來難的任務 AI 做得很好、某些看起來簡單的任務 AI 反而做不好。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/llm/knowledge-cards/human-in-the-loop/" data-link-title="Human-in-the-loop（HITL）" data-link-desc="人類介入 LLM 工作流的設計：三種觸發時機（pre-act / mid-stream / post-hoc）、避免橡皮圖章化的四條件">HITL（human-in-the-loop）</a> 設計的本質是<strong>在「人類介入頻率」spectrum 上選位置</strong>——位置由 risk（副作用範圍 + 失敗代價）跟自動 validator 能力決定。risk 高 + validator 弱、人類介入頻率高；risk 低 + validator 強、人類介入頻率低。落點選錯就會出兩種事故：自動化過度跑 production migration 是 over-trust、每個 tool call 都要 approval 是 under-trust。</p>
<p>本章寫人機協作的拓樸設計：兩種工作模式（centaur / cyborg）、能力邊界的不規則性（jagged frontier）、三種 HITL 觸發時機、跟 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 自主度分層</a> 的對應。這層問題是跨產品 / 跨領域通用、跟具體 framework 無關。</p>
<h2 id="本章目標">本章目標</h2>
<p>讀完本章後你能：</p>
<ol>
<li>區分 centaur 跟 cyborg 兩種工作模式、判斷哪種適合哪種任務。</li>
<li>描述 jagged frontier、解釋為什麼「全自動」是錯題。</li>
<li>在 pre-act / mid-stream / post-hoc 三個時機點選對 HITL 設計。</li>
<li>設計確認流程、避免人類變橡皮圖章。</li>
<li>把這層設計對應回 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a> 的自主度分層。</li>
</ol>
<h2 id="兩種工作模式centaur-跟-cyborg">兩種工作模式：Centaur 跟 Cyborg</h2>
<p>Centaur 跟 cyborg 是兩種人類跟 LLM 共事的姿態。概念起源於 Kasparov 2010 提的 advanced chess（人類 + AI 配合下棋）、HBS / UPenn / Wharton 對 BCG 顧問使用 AI 的研究把這對 framing 套到 knowledge work、觀察到兩種使用模式都存在且各有適用。</p>
<h3 id="centaur-模式">Centaur 模式</h3>
<p>人類把整段任務委派給 LLM、等結果回來再審。</p>
<ul>
<li><strong>比喻</strong>：人馬獸——上半身人、下半身馬、清楚的職責分工。</li>
<li><strong>典型場景</strong>：「寫一份這個主題的 PPT 大綱、含三個案例、按以下風格、做完給我」、LLM 跑幾分鐘、人類審結果。</li>
<li><strong>適合</strong>：任務邊界清楚、人類能事先描述完整需求、結果可離線審。</li>
<li><strong>失敗模式</strong>：任務描述漏細節、LLM 跑偏到沒注意、結果不能用。緩解：先給小範圍試跑、確認方向再放手。</li>
</ul>
<h3 id="cyborg-模式">Cyborg 模式</h3>
<p>人類跟 LLM 緊密協作、快速來回、人類隨時調整方向。</p>
<ul>
<li><strong>比喻</strong>：半機械人——人類跟 LLM 融合、邊做邊改。</li>
<li><strong>典型場景</strong>：寫 code 時 IDE 內 inline completion、寫文章時邊輸入邊看 LLM 建議、debug 時來回問。</li>
<li><strong>適合</strong>：任務探索性、需求邊做邊浮現、無法事先完整描述。</li>
<li><strong>失敗模式</strong>：頻繁打斷思路、context switch 成本高、最後產出反而比 centaur 慢。緩解：對熟悉的任務 cyborg、不熟的任務 centaur。</li>
</ul>
<h3 id="該用哪種">該用哪種</h3>
<table>
  <thead>
      <tr>
          <th>任務性質</th>
          <th>預設模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>邊界清楚、需求可事先描述完整</td>
          <td>Centaur</td>
      </tr>
      <tr>
          <td>探索性、邊做邊定義</td>
          <td>Cyborg</td>
      </tr>
      <tr>
          <td>大量重複（如 100 篇文章）</td>
          <td>Centaur</td>
      </tr>
      <tr>
          <td>創意 / 設計、要看回饋微調</td>
          <td>Cyborg</td>
      </tr>
      <tr>
          <td>高代價、要 rollback 控制</td>
          <td>Centaur + 強 review</td>
      </tr>
  </tbody>
</table>
<p>學生 / 個人開發更常 cyborg 工作、企業自動化更常 centaur 工作。看到一個產品設計時、問「它鼓勵 user 走 centaur 還是 cyborg」、就能判讀它的設計取向。</p>
<h2 id="jagged-frontierai-能力的不規則邊界">Jagged Frontier：AI 能力的不規則邊界</h2>
<p><a href="/blog/llm/knowledge-cards/jagged-frontier/" data-link-title="Jagged frontier" data-link-desc="AI 能力分佈不規則的 framing：某些看似簡單的任務 AI 容易壞、某些看似複雜的任務 AI 反而做得好">Jagged frontier</a> 是觀察 AI 能力分佈的 framing。直覺上「AI 能做的任務」應該是一個 smooth 的連續區、簡單的能做、難的不能。實際上不是——AI 能做的任務分佈是<strong>鋸齒狀（jagged）</strong>：某些看起來難的任務 AI 做得很好、某些看起來簡單的任務 AI 反而做不好。</p>
<table>
  <thead>
      <tr>
          <th>看起來簡單但 AI 容易壞</th>
          <th>看起來複雜但 AI 做得好</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>精確算術</td>
          <td>寫一段風格指定的程式碼</td>
      </tr>
      <tr>
          <td>計數（這段有幾個字）</td>
          <td>翻譯複雜技術文章</td>
      </tr>
      <tr>
          <td>嚴格遵守冷僻格式</td>
          <td>從一段文字抽取關鍵 entity</td>
      </tr>
      <tr>
          <td>引用真實的 URL</td>
          <td>解釋複雜概念</td>
      </tr>
  </tbody>
</table>
<p>這張表是 2024-2025 的觀察、<strong>frontier 會隨模型升級漂移</strong>——reasoning model + tool use 普及後、算術跟計數已經部分往「能做」那邊移、URL 也可以靠 web search tool 補救。表的價值在於 framing「能力分佈不規則」、不是把具體 4 個 case 當定論。</p>
<p>每個例子背後的失敗機制各不相同：</p>
<ul>
<li><strong>精確算術</strong>：靠符號操作、訓練資料中算術佔比小、tokenizer 把數字切成多 token 也加難度。Tool use（呼叫 calculator）能補救。</li>
<li><strong>計數</strong>：要對 input 做精確 traversal、跟 LLM 的並行 <a href="/blog/llm/knowledge-cards/attention/" data-link-title="Attention" data-link-desc="Transformer 內部讓每個 token 對其他 token 加權平均的核心機制、形成 KV cache 跟 context window 的計算基礎">attention</a> 機制不對盤、容易少算多算。對 needle in long context 的失敗模式類比見 <a href="/blog/llm/knowledge-cards/needle-in-haystack/" data-link-title="Needle in a Haystack" data-link-desc="把一個事實藏在 long context 不同位置、測試 LLM 能否抓出來的 benchmark 方法">needle in haystack</a> 卡。</li>
<li><strong>嚴格遵守冷僻格式</strong>：format 沒在訓練分佈中見過、模型回退到「我熟悉的格式」。Constrained decoding（見 <a href="/blog/llm/03-theoretical-foundations/constrained-decoding-internals/" data-link-title="3.10 Constrained decoding 內部：grammar mask 跟性能取捨" data-link-desc="Constrained decoding 的內部運作：token mask 計算、JSON schema / regex / CFG 三種 grammar、XGrammar pre-compile 機制、性能反而加速">3.10</a>）能補救。</li>
<li><strong>引用真實 URL</strong>：模型沒辦法區分「真實存在」跟「看起來合理」、<a href="/blog/llm/knowledge-cards/hallucination/" data-link-title="Hallucination" data-link-desc="LLM 生成內容看起來合理但事實錯誤、引用不存在的來源、虛構不存在的 entity 的現象">hallucinate</a> 出格式對但內容假的 URL。靠 tool（web search、URL validator）才能驗證。</li>
</ul>
<p>整體看：能力分佈跟訓練資料分佈、tokenizer 行為、推論機制相關、跟人類直覺的「難易」沒對齊。這給三個實務啟示：</p>
<ul>
<li><strong>不要用「人類直覺難易」推測 AI 能力</strong>。試跑、看結果、不要預判。</li>
<li><strong>「全自動」是 over-trust 假設</strong>：frontier 鋸齒、總有些子任務落在 frontier 外、需要人介入或 tool 補。設計時要假設「有部分子任務 AI 會失敗」、而不是「都會成功」。</li>
<li><strong>失敗在 frontier 外的任務、再加 prompt iteration 通常無效</strong>：那是模型能力邊界問題、不是 prompt 問題。對應 <a href="/blog/llm/04-applications/prompt-techniques-landscape/" data-link-title="4.0 Prompt 技術光譜：手法分類、取捨、組合模式" data-link-desc="Zero-shot / few-shot、chain-of-thought、role / template、reflection 等 prompt 技術的分類與取捨、何時 stack 何時不要 stack、跟 fine-tune / RAG / chaining 的邊界">4.0 prompt 技術光譜</a> 的 systematic vs random error 診斷。</li>
</ul>
<h3 id="falling-asleep-at-the-wheelfrontier-外的隱性風險">Falling asleep at the wheel：frontier 外的隱性風險</h3>
<p>研究發現一個跟 jagged frontier 互動的人類行為模式：<strong>人類傾向不分辨任務是否在 frontier 內、對 AI 結果一律低度審查</strong>。結果 frontier 內的任務 AI 做得好、人類審不審差別不大；frontier 外的任務 AI 做得差、但人類也沒審出來、產出帶錯送出。</p>
<p>緩解：</p>
<ul>
<li><strong>明確標 frontier</strong>：對團隊 / 產品 user 標出「AI 在這類任務可靠 / 不可靠」、不要假設 user 會自己分辨。</li>
<li><strong>frontier 外的任務強制人類審查</strong>：把「該審 vs 不該審」做成 deterministic 規則、不交給 user 自由心證。</li>
<li><strong>抽樣審查</strong>：即使 frontier 內任務、隨機抽樣審查、偵測 frontier 漂移（模型升級或 prompt 變動後 frontier 可能移動）。</li>
</ul>
<h2 id="hitl-三種觸發時機">HITL 三種觸發時機</h2>
<p>人類介入的時機決定 HITL 的型態。三個時機點各有適用場景：</p>
<h3 id="pre-act動作執行前確認">Pre-act：動作執行前確認</h3>
<p>LLM 決定要做某個 action、但 action 真的執行前停下來、給人類審 + approve。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">LLM decides: 「我要刪除 user_id=123 的 record」
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">[HUMAN APPROVE?]
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓ (approved)
</span></span><span class="line"><span class="ln">5</span><span class="cl">Execute deletion</span></span></code></pre></div><ul>
<li><strong>適用</strong>：不可逆 / 高代價的 action。對應 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent</a> 的「step-by-step approval」協作模型。</li>
<li><strong>失敗模式</strong>：approval 流程太頻繁、人類疲勞、最後變橡皮圖章。緩解見後面「避免橡皮圖章化」段。</li>
</ul>
<h3 id="mid-stream執行過程中介入">Mid-stream：執行過程中介入</h3>
<p>Agent loop 跑到一半、發現自己不確定、主動停下來問人類。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Agent: 「我有兩個方案、不確定哪個、請選 A 還是 B？」
</span></span><span class="line"><span class="ln">2</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">[HUMAN PICKS]
</span></span><span class="line"><span class="ln">4</span><span class="cl">   ↓
</span></span><span class="line"><span class="ln">5</span><span class="cl">Agent continues with chosen path</span></span></code></pre></div><ul>
<li><strong>適用</strong>：任務有多個合理路徑、選擇影響後續策略、不該由 agent 自決。</li>
<li><strong>跟 pre-act 的差異</strong>：pre-act 是「我準備做 X、你 approve 嗎」（agent 已決定方向）、mid-stream 是「我不確定該做什麼、你決定」（決策權交給人類）。</li>
<li><strong>失敗模式</strong>：agent 不知道自己該不知道（unknown unknowns）、該問沒問、自己亂走。緩解：在 prompt 內 enumerate 常見的「該問人類」情境、降低 agent 自決的範圍。</li>
</ul>
<h3 id="post-hoc事後申訴--校正">Post-hoc：事後申訴 / 校正</h3>
<p>Agent 已執行、結果交付、user 看完後可以申訴 / 校正。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Agent produces result → User sees result
</span></span><span class="line"><span class="ln">2</span><span class="cl">                              ↓
</span></span><span class="line"><span class="ln">3</span><span class="cl">                       [USER APPEALS?]
</span></span><span class="line"><span class="ln">4</span><span class="cl">                              ↓ (yes)
</span></span><span class="line"><span class="ln">5</span><span class="cl">                       Human reviews + adjusts
</span></span><span class="line"><span class="ln">6</span><span class="cl">                              ↓
</span></span><span class="line"><span class="ln">7</span><span class="cl">                       Feedback loop → 改 prompt / fine-tune</span></span></code></pre></div><ul>
<li><strong>適用</strong>：行為層次的細節調整、評分類任務（如自動打分後 user 申訴）、預先審不可行的場景。</li>
<li><strong>跟 pre/mid 的差異</strong>：post-hoc 不擋執行流、執行完才介入；前兩者擋在執行前 / 執行中。</li>
<li><strong>典型例子</strong>：自動評分系統的 appeal 流程——LLM 打分完、user 對分數有異議時、走人類審查、結果不只改這次分數、還回饋進系統改善後續評分。</li>
<li><strong>失敗模式</strong>：appeal rate 過高（系統信任度差）、或 appeal rate 過低（user 不知道可以申訴 / 申訴成本高）、回饋訊號失真。</li>
</ul>
<h3 id="三個時機的選擇">三個時機的選擇</h3>
<table>
  <thead>
      <tr>
          <th>時機</th>
          <th>適合任務</th>
          <th>不適合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Pre-act</td>
          <td>高代價、不可逆、副作用範圍大</td>
          <td>高頻率動作（會把人類淹死）</td>
      </tr>
      <tr>
          <td>Mid-stream</td>
          <td>路徑分歧、需要 domain judgment</td>
          <td>路徑可由 agent 自決的低代價任務</td>
      </tr>
      <tr>
          <td>Post-hoc</td>
          <td>評分 / 評估、低代價、user 數量大</td>
          <td>不可逆動作（事後 appeal 來不及）</td>
      </tr>
  </tbody>
</table>
<p>實務多重組合：pre-act 擋高代價、mid-stream 處理 agent 的不確定性、post-hoc 收 user 回饋改善系統。<strong>三者各自處理不同 risk class、不互斥</strong>。</p>
<h2 id="有效-hitl-的四個設計條件">有效 HITL 的四個設計條件</h2>
<p>HITL 要真的擋住失敗、不退化成 rubber-stamp approval、設計上要滿足四個條件。每個條件對應一個常見退化模式、可以同時當 checklist 用。</p>
<h3 id="條件一分級不同-risk-走不同-gate">條件一：分級、不同 risk 走不同 gate</h3>
<p>高 risk 動作（push、deploy、production change）強制 step-by-step approval；中等 risk（檔案寫入、本機 commit）每 N 步 checkpoint；低 risk（read-only、本機 sandbox）full auto。對應 <a href="/blog/llm/04-applications/tool-use-principles/" data-link-title="4.3 Tool use 原理：LLM 跟外部世界互動" data-link-desc="Structured output 是 LLM 跨入工程系統的橋、function calling 取捨、為什麼本地小模型 tool use 表現崩潰">4.3 tool use 副作用範圍</a> 的等級分類。</p>
<p>對應反例：每個 tool call 都要 approve、不分高低代價、user 每天按 100 次 approve、按到下意識、根本沒看內容。</p>
<h3 id="條件二approval-ui-強制-show-diff">條件二：approval UI 強制 show diff</h3>
<p>審查的具體內容（準備寫的檔案內容、準備執行的 SQL、準備發的 email 草稿）必須在 approval UI 上呈現、user 看得到才能做出有意義的判斷。</p>
<p>對應反例：「approve this action?」按鈕、但 user 看不到 action 的具體內容、只能盲簽。沒有 diff 就沒有審查、不要假裝有審查。</p>
<h3 id="條件三reject-有明確-fallback-路徑">條件三：reject 有明確 fallback 路徑</h3>
<p>User reject 後 agent 該怎麼處理（換方案、停下來、escalate）要在設計時確定、不能讓「reject 等同流程斷」。</p>
<p>對應反例：只能 approve、reject 的話 agent 不知道怎麼辦、user 怕 reject 後續流程斷、就一律按 approve、HITL 失去意義。</p>
<h3 id="條件四approval-訊號要回饋進系統">條件四：approval 訊號要回饋進系統</h3>
<p>User 的 approve / reject pattern 進 trace、定期 analyze、把「總是 approve 的動作」自動降級、「總是 reject 的動作」進 prompt 改變 agent 預設行為。</p>
<p>對應反例：User 一直 approve / reject、但訊號沒回饋、agent 下次還是問一樣的問題、user 疲勞累積。</p>
<h2 id="跟-agent-自主度分層的對應">跟 Agent 自主度分層的對應</h2>
<p><a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4 agent 架構</a> 列了五種人類審查協作模型：full auto、checkpoint、step-by-step approval、plan first then auto、human-in-the-loop。本章三種 HITL 時機跟這五種協作模型的對應：</p>
<table>
  <thead>
      <tr>
          <th>Agent 自主度分層</th>
          <th>主要 HITL 時機</th>
          <th>設計重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Full auto</td>
          <td>Post-hoc</td>
          <td>Appeal 流程、抽樣審查、distribution monitoring</td>
      </tr>
      <tr>
          <td>Checkpoint</td>
          <td>Pre-act（每 N 步）</td>
          <td>分級 approval、diff 必須 show</td>
      </tr>
      <tr>
          <td>Step-by-step approval</td>
          <td>Pre-act（每步）</td>
          <td>UI 簡潔、reject 路徑清楚、避免疲勞</td>
      </tr>
      <tr>
          <td>Plan first, then auto</td>
          <td>Pre-act（plan 階段）+ Post-hoc</td>
          <td>Plan diff + 執行後審查</td>
      </tr>
      <tr>
          <td>Human-in-the-loop（mid-stream）</td>
          <td>Mid-stream</td>
          <td>Agent 知道自己該問人類、不該問的事不問</td>
      </tr>
  </tbody>
</table>
<p>選哪一層、看 <a href="/blog/llm/04-applications/tool-use-principles/" data-link-title="4.3 Tool use 原理：LLM 跟外部世界互動" data-link-desc="Structured output 是 LLM 跨入工程系統的橋、function calling 取捨、為什麼本地小模型 tool use 表現崩潰">4.3 工具副作用範圍</a> 等級：等級 1-2 用 full auto + post-hoc、等級 3 用 checkpoint、等級 4-5 強制 step-by-step。</p>
<h2 id="跟-fuzzy-engineering-典範的關係">跟 Fuzzy Engineering 典範的關係</h2>
<p><a href="/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/" data-link-title="0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移" data-link-desc="傳統 deterministic 軟體跟 fuzzy LLM 軟體在資料、邏輯、分解、實驗成本四個維度的根本差異、以及哪段該 deterministic、哪段該 fuzzy 的決策框架">0.8 Deterministic vs Fuzzy Engineering</a> 講 fuzzy 邊界要包 deterministic guardrail。HITL 是 guardrail 的一個 case——把人類判斷當成 deterministic check 來包 fuzzy LLM 行為。</p>
<p>判讀 HITL 該存在的訊號：</p>
<ul>
<li>任務的 fuzzy 行為輸出進入不可逆 deterministic 系統（DB write、API call、實體 action）。</li>
<li>LLM 在這類 boundary 上的失敗代價遠高於 HITL 的人類 cost。</li>
<li>沒有可靠的自動 validator（用 LLM judge 風險也太高）。</li>
</ul>
<p>三者俱備時、HITL 是必要的 guardrail。任一不滿足、可能用 schema validation / output validator / distribution monitoring 替代、不需要人類在 loop 內。</p>
<h2 id="何時過時--何時不過時">何時過時 / 何時不過時</h2>
<p><strong>不會過時的部分</strong>：</p>
<ul>
<li>Centaur vs cyborg 兩種工作模式的分類。</li>
<li>Jagged frontier 概念、「全自動」是錯題的論證。</li>
<li>三種 HITL 觸發時機（pre-act / mid-stream / post-hoc）的分類。</li>
<li>橡皮圖章化的四個反模式跟緩解。</li>
<li>跟 agent 自主度分層、fuzzy engineering 典範的對應結構。</li>
</ul>
<p><strong>會變的部分</strong>：</p>
<ul>
<li>Jagged frontier 的具體位置（哪些任務在 frontier 內、隨模型能力進步會移動）。</li>
<li>HITL 的 UI / UX 工具（隨產品 framework 演化）。</li>
<li>Approval 自動化的程度（更強的 distribution monitoring 可能讓部分 HITL 變得不必要）。</li>
</ul>
<h2 id="下一章">下一章</h2>
<p>下一章：<a href="/blog/llm/04-applications/application-protocols/" data-link-title="4.6 應用層協議：function calling / structured output / MCP" data-link-desc="三個常被混為一談的概念：模型能力、sampling 約束、server 協議，三者的層級差異與組合方式">4.6 應用層協議</a>、把 function calling / structured output / MCP 三個概念放回正確層級、銜接 agent 跟外部系統的協議設計。Agent 自主度分層完整討論見 <a href="/blog/llm/04-applications/agent-architecture/" data-link-title="4.4 Agent 架構原理" data-link-desc="Agent loop 結構、失敗模式、什麼任務適合 vs 不適合、跟人類審查的協作模型">4.4</a>、工具副作用範圍見 <a href="/blog/llm/04-applications/tool-use-principles/" data-link-title="4.3 Tool use 原理：LLM 跟外部世界互動" data-link-desc="Structured output 是 LLM 跨入工程系統的橋、function calling 取捨、為什麼本地小模型 tool use 表現崩潰">4.3</a>、HITL 在 fuzzy engineering 中的定位見 <a href="/blog/llm/00-foundations/deterministic-vs-fuzzy-engineering/" data-link-title="0.8 Deterministic vs Fuzzy Engineering：軟體設計典範的位移" data-link-desc="傳統 deterministic 軟體跟 fuzzy LLM 軟體在資料、邏輯、分解、實驗成本四個維度的根本差異、以及哪段該 deterministic、哪段該 fuzzy 的決策框架">0.8</a>。</p>
]]></content:encoded></item><item><title>Filter 順序由使用者掃描成本決定</title><link>https://tarrragon.github.io/blog/report/filter-order-by-scan-cost/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/filter-order-by-scan-cost/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>選項清單按使用者掃描順序排序、不按資料來源預設。&lt;/strong> 短清單先看完、長清單花更多時間 — 把短清單放前面讓使用者先排除一個維度、再面對長清單。字母排序對「找已知名稱」有效；多選 facet 場景下使用者通常不知道確切選項名、需要 scan，這時候掃描成本主導。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼掃描成本優先於字母排序">為什麼掃描成本優先於字母排序&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>當清單有多個選項供使用者挑選，&lt;strong>選項數量影響掃描時間&lt;/strong>。使用者在 facet UI 的行為不是「找已知 tag」、是「看到有什麼可選、選有興趣的」 — 這是探索式行為、不是查找式行為。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>行為類型&lt;/th>
 &lt;th>適合的排序&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>查找式（知道要什麼）&lt;/td>
 &lt;td>字母排序 — 二分查找&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>探索式（看有什麼）&lt;/td>
 &lt;td>掃描成本排序 — 短清單先&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>字母排序是資料庫思維（穩定、可預測），掃描成本排序是 UX 思維（縮短決策時間）。Facet 場景幾乎都是後者。&lt;/p>
&lt;h3 id="兩條維度收斂的順序">兩條維度收斂的順序&lt;/h3>
&lt;p>當有多個 facet 維度可選、使用者通常&lt;strong>逐維度收斂&lt;/strong>：先用一個維度砍掉一半結果、再用第二個維度精選。短清單放前面讓「第一刀」決策快速。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的應用">這次任務的應用&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>Pagefind filter 預設按 filter key 字母排序：&lt;code>tag&lt;/code> &amp;lt; &lt;code>type&lt;/code>，所以 Tag 先顯示。&lt;/p>
&lt;p>實際內容：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Filter&lt;/th>
 &lt;th>選項數量&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Type&lt;/td>
 &lt;td>~5 個（post / card / glossary 等 section）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Tag&lt;/td>
 &lt;td>~80 個（站上所有 tags）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>Type 短、Tag 長 — 預期使用者行為是「先按 type 收斂（這是 post 還是 glossary？）、再進 tag 找主題」。Tag 在前等於要使用者先面對 80 個選項，認知成本高。&lt;/p>
&lt;p>把順序倒過來：Type 先顯示讓使用者先用 section 收斂、再進 Tag 找。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">reorderFilters&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">filter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-block&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">desiredOrder&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;type&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;tag&amp;#39;&lt;/span>&lt;span class="p">];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">byKey&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">blocks&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">b&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kd">var&lt;/span> &lt;span class="nx">key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">b&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__filter-name&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">textContent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">trim&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">toLowerCase&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">byKey&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">key&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">b&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">desiredOrder&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">forEach&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">k&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">byKey&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">k&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">filter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">appendChild&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">byKey&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nx">k&lt;/span>&lt;span class="p">]));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="內在屬性比較四種排序策略">內在屬性比較：四種排序策略&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>策略&lt;/th>
 &lt;th>適合場景&lt;/th>
 &lt;th>失敗模式&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>字母排序&lt;/td>
 &lt;td>使用者知道確切選項名&lt;/td>
 &lt;td>探索式場景下掃描慢&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>掃描成本排序（短先長後）&lt;/td>
 &lt;td>探索式 facet&lt;/td>
 &lt;td>「短」「長」邊界模糊時不穩&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用頻率排序&lt;/td>
 &lt;td>有 analytics 資料&lt;/td>
 &lt;td>冷啟動時無資料、需要默認&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>語意分組排序&lt;/td>
 &lt;td>選項有明顯子類別&lt;/td>
 &lt;td>子類別劃分本身需要設計&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇順序：&lt;strong>有 analytics → 頻率排序；沒有 → 掃描成本排序；都不適用 → 字母排序&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="排序原則的延伸應用">排序原則的延伸應用&lt;/h2>
&lt;h3 id="同類-facet-內的選項排序">同類 facet 內的選項排序&lt;/h3>
&lt;p>Tag 內部的 80 個 tag 怎麼排？&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>內部排序方式&lt;/th>
 &lt;th>適用&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>字母排序&lt;/td>
 &lt;td>使用者知道想找哪個 tag&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>計數排序（最多文章的 tag 在前）&lt;/td>
 &lt;td>探索式、引導使用者進熱門主題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>編輯精選&lt;/td>
 &lt;td>站方有特定主題策略&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>當前接受 pagefind 的字母排序（成本最低、且 80 個 tag 做計數排序需要額外索引處理）。&lt;/p>
&lt;h3 id="選項數量門檻">選項數量門檻&lt;/h3>
&lt;p>短清單跟長清單的邊界沒有絕對值，常用啟發：&lt;/p>
&lt;ul>
&lt;li>≤ 7 個選項：可一眼掃完、放前面&lt;/li>
&lt;li>8-20 個：中等、需要結構（縮排、分組）&lt;/li>
&lt;li>20+ 個：長清單、放後面或加搜尋框&lt;/li>
&lt;/ul>
&lt;p>Type 5 個落在「短」、Tag 80 個落在「長」 — 順序明顯。&lt;/p>
&lt;hr>
&lt;h2 id="設計取捨選項清單的排序策略">設計取捨：選項清單的排序策略&lt;/h2>
&lt;p>四種做法、各自機會成本不同。預設依使用者行為性質選 — 探索式 → A、查找式 → B、有 analytics → C、有子類別 → D。&lt;/p>
&lt;h3 id="a掃描成本排序短先長後探索式-facet-的預設">A：掃描成本排序（短先長後）（探索式 facet 的預設）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：選項數量少的維度放前面（type 5 個 → 先；tag 80 個 → 後）&lt;/li>
&lt;li>&lt;strong>選 A 的理由&lt;/strong>：使用者先用短清單收斂一刀、再面對長清單；認知成本低&lt;/li>
&lt;li>&lt;strong>適合&lt;/strong>：探索式 facet（使用者不知道確切選項名）&lt;/li>
&lt;li>&lt;strong>代價&lt;/strong>：需要主動覆寫資料來源預設（不能直接用 DB 順序）&lt;/li>
&lt;/ul>
&lt;h3 id="b字母排序">B：字母排序&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：按 alphabetical 排&lt;/li>
&lt;li>&lt;strong>跟 A 的取捨&lt;/strong>：B 對「找已知名稱」高效（二分查找）、A 對「探索式選擇」高效；但 facet 場景幾乎都是探索式&lt;/li>
&lt;li>&lt;strong>B 比 A 好的情境&lt;/strong>：使用者通常知道確切選項名（國家清單、語言清單）&lt;/li>
&lt;/ul>
&lt;h3 id="c使用頻率排序最常用在前">C：使用頻率排序（最常用在前）&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：按 analytics 統計、最高頻選項在前&lt;/li>
&lt;li>&lt;strong>跟 A/B 的取捨&lt;/strong>：C 比 A 更精準（用真實資料）、但需要 analytics + 冷啟動時無資料&lt;/li>
&lt;li>&lt;strong>C 比 A 好的情境&lt;/strong>：有足夠 analytics、且使用者偏好集中（80/20 分布明顯）&lt;/li>
&lt;/ul>
&lt;h3 id="d語意分組排序">D：語意分組排序&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>機制&lt;/strong>：把選項分子類別、組內再排&lt;/li>
&lt;li>&lt;strong>跟 A 的取捨&lt;/strong>：D 對「選項有明顯子類別」更直觀（產品類別 / 功能類別）、A 對純扁平清單夠用&lt;/li>
&lt;li>&lt;strong>D 比 A 好的情境&lt;/strong>：選項有清楚的層級結構（電商 facet：類別 &amp;gt; 子類別 &amp;gt; 選項）&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="判讀徵兆">判讀徵兆&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>訊號&lt;/th>
 &lt;th>排序問題&lt;/th>
 &lt;th>修正動作&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>使用者抱怨「選項太多找不到」&lt;/td>
 &lt;td>長清單在前、掃描負擔大&lt;/td>
 &lt;td>把短清單前移、或加搜尋框&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同類選項使用者卡在第一步&lt;/td>
 &lt;td>第一個維度選項過多&lt;/td>
 &lt;td>換成更少選項的維度當第一步&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>預設排序看起來「隨機」&lt;/td>
 &lt;td>資料來源排序與 UX 不符&lt;/td>
 &lt;td>主動 reorder、不接受預設&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>新增選項後順序錯亂&lt;/td>
 &lt;td>reorder 邏輯依賴 hardcoded list&lt;/td>
 &lt;td>改用屬性分類（例如選項數量自動排）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>核心原則&lt;/strong>：UI 排序是設計決策、不是技術選擇。預設順序通常反映資料來源結構、不反映使用者行為 — 主動覆寫是常態、不是例外。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>選項清單按使用者掃描順序排序、不按資料來源預設。</strong> 短清單先看完、長清單花更多時間 — 把短清單放前面讓使用者先排除一個維度、再面對長清單。字母排序對「找已知名稱」有效；多選 facet 場景下使用者通常不知道確切選項名、需要 scan，這時候掃描成本主導。</p>
<hr>
<h2 id="為什麼掃描成本優先於字母排序">為什麼掃描成本優先於字母排序</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>當清單有多個選項供使用者挑選，<strong>選項數量影響掃描時間</strong>。使用者在 facet UI 的行為不是「找已知 tag」、是「看到有什麼可選、選有興趣的」 — 這是探索式行為、不是查找式行為。</p>
<table>
  <thead>
      <tr>
          <th>行為類型</th>
          <th>適合的排序</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>查找式（知道要什麼）</td>
          <td>字母排序 — 二分查找</td>
      </tr>
      <tr>
          <td>探索式（看有什麼）</td>
          <td>掃描成本排序 — 短清單先</td>
      </tr>
  </tbody>
</table>
<p>字母排序是資料庫思維（穩定、可預測），掃描成本排序是 UX 思維（縮短決策時間）。Facet 場景幾乎都是後者。</p>
<h3 id="兩條維度收斂的順序">兩條維度收斂的順序</h3>
<p>當有多個 facet 維度可選、使用者通常<strong>逐維度收斂</strong>：先用一個維度砍掉一半結果、再用第二個維度精選。短清單放前面讓「第一刀」決策快速。</p>
<hr>
<h2 id="這次任務的應用">這次任務的應用</h2>
<h3 id="觀察">觀察</h3>
<p>Pagefind filter 預設按 filter key 字母排序：<code>tag</code> &lt; <code>type</code>，所以 Tag 先顯示。</p>
<p>實際內容：</p>
<table>
  <thead>
      <tr>
          <th>Filter</th>
          <th>選項數量</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Type</td>
          <td>~5 個（post / card / glossary 等 section）</td>
      </tr>
      <tr>
          <td>Tag</td>
          <td>~80 個（站上所有 tags）</td>
      </tr>
  </tbody>
</table>
<h3 id="判讀">判讀</h3>
<p>Type 短、Tag 長 — 預期使用者行為是「先按 type 收斂（這是 post 還是 glossary？）、再進 tag 找主題」。Tag 在前等於要使用者先面對 80 個選項，認知成本高。</p>
<p>把順序倒過來：Type 先顯示讓使用者先用 section 收斂、再進 Tag 找。</p>
<h3 id="執行">執行</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">reorderFilters</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">var</span> <span class="nx">blocks</span> <span class="o">=</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-block&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kd">var</span> <span class="nx">desiredOrder</span> <span class="o">=</span> <span class="p">[</span><span class="s1">&#39;type&#39;</span><span class="p">,</span> <span class="s1">&#39;tag&#39;</span><span class="p">];</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">var</span> <span class="nx">byKey</span> <span class="o">=</span> <span class="p">{};</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">blocks</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">b</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="kd">var</span> <span class="nx">key</span> <span class="o">=</span> <span class="nx">b</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-name&#39;</span><span class="p">).</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">trim</span><span class="p">().</span><span class="nx">toLowerCase</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">byKey</span><span class="p">[</span><span class="nx">key</span><span class="p">]</span> <span class="o">=</span> <span class="nx">b</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nx">desiredOrder</span><span class="p">.</span><span class="nx">forEach</span><span class="p">(</span><span class="nx">k</span> <span class="p">=&gt;</span> <span class="nx">byKey</span><span class="p">[</span><span class="nx">k</span><span class="p">]</span> <span class="o">&amp;&amp;</span> <span class="nx">filter</span><span class="p">.</span><span class="nx">appendChild</span><span class="p">(</span><span class="nx">byKey</span><span class="p">[</span><span class="nx">k</span><span class="p">]));</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="內在屬性比較四種排序策略">內在屬性比較：四種排序策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適合場景</th>
          <th>失敗模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>字母排序</td>
          <td>使用者知道確切選項名</td>
          <td>探索式場景下掃描慢</td>
      </tr>
      <tr>
          <td>掃描成本排序（短先長後）</td>
          <td>探索式 facet</td>
          <td>「短」「長」邊界模糊時不穩</td>
      </tr>
      <tr>
          <td>使用頻率排序</td>
          <td>有 analytics 資料</td>
          <td>冷啟動時無資料、需要默認</td>
      </tr>
      <tr>
          <td>語意分組排序</td>
          <td>選項有明顯子類別</td>
          <td>子類別劃分本身需要設計</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>有 analytics → 頻率排序；沒有 → 掃描成本排序；都不適用 → 字母排序</strong>。</p>
<hr>
<h2 id="排序原則的延伸應用">排序原則的延伸應用</h2>
<h3 id="同類-facet-內的選項排序">同類 facet 內的選項排序</h3>
<p>Tag 內部的 80 個 tag 怎麼排？</p>
<table>
  <thead>
      <tr>
          <th>內部排序方式</th>
          <th>適用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>字母排序</td>
          <td>使用者知道想找哪個 tag</td>
      </tr>
      <tr>
          <td>計數排序（最多文章的 tag 在前）</td>
          <td>探索式、引導使用者進熱門主題</td>
      </tr>
      <tr>
          <td>編輯精選</td>
          <td>站方有特定主題策略</td>
      </tr>
  </tbody>
</table>
<p>當前接受 pagefind 的字母排序（成本最低、且 80 個 tag 做計數排序需要額外索引處理）。</p>
<h3 id="選項數量門檻">選項數量門檻</h3>
<p>短清單跟長清單的邊界沒有絕對值，常用啟發：</p>
<ul>
<li>≤ 7 個選項：可一眼掃完、放前面</li>
<li>8-20 個：中等、需要結構（縮排、分組）</li>
<li>20+ 個：長清單、放後面或加搜尋框</li>
</ul>
<p>Type 5 個落在「短」、Tag 80 個落在「長」 — 順序明顯。</p>
<hr>
<h2 id="設計取捨選項清單的排序策略">設計取捨：選項清單的排序策略</h2>
<p>四種做法、各自機會成本不同。預設依使用者行為性質選 — 探索式 → A、查找式 → B、有 analytics → C、有子類別 → D。</p>
<h3 id="a掃描成本排序短先長後探索式-facet-的預設">A：掃描成本排序（短先長後）（探索式 facet 的預設）</h3>
<ul>
<li><strong>機制</strong>：選項數量少的維度放前面（type 5 個 → 先；tag 80 個 → 後）</li>
<li><strong>選 A 的理由</strong>：使用者先用短清單收斂一刀、再面對長清單；認知成本低</li>
<li><strong>適合</strong>：探索式 facet（使用者不知道確切選項名）</li>
<li><strong>代價</strong>：需要主動覆寫資料來源預設（不能直接用 DB 順序）</li>
</ul>
<h3 id="b字母排序">B：字母排序</h3>
<ul>
<li><strong>機制</strong>：按 alphabetical 排</li>
<li><strong>跟 A 的取捨</strong>：B 對「找已知名稱」高效（二分查找）、A 對「探索式選擇」高效；但 facet 場景幾乎都是探索式</li>
<li><strong>B 比 A 好的情境</strong>：使用者通常知道確切選項名（國家清單、語言清單）</li>
</ul>
<h3 id="c使用頻率排序最常用在前">C：使用頻率排序（最常用在前）</h3>
<ul>
<li><strong>機制</strong>：按 analytics 統計、最高頻選項在前</li>
<li><strong>跟 A/B 的取捨</strong>：C 比 A 更精準（用真實資料）、但需要 analytics + 冷啟動時無資料</li>
<li><strong>C 比 A 好的情境</strong>：有足夠 analytics、且使用者偏好集中（80/20 分布明顯）</li>
</ul>
<h3 id="d語意分組排序">D：語意分組排序</h3>
<ul>
<li><strong>機制</strong>：把選項分子類別、組內再排</li>
<li><strong>跟 A 的取捨</strong>：D 對「選項有明顯子類別」更直觀（產品類別 / 功能類別）、A 對純扁平清單夠用</li>
<li><strong>D 比 A 好的情境</strong>：選項有清楚的層級結構（電商 facet：類別 &gt; 子類別 &gt; 選項）</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>排序問題</th>
          <th>修正動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者抱怨「選項太多找不到」</td>
          <td>長清單在前、掃描負擔大</td>
          <td>把短清單前移、或加搜尋框</td>
      </tr>
      <tr>
          <td>同類選項使用者卡在第一步</td>
          <td>第一個維度選項過多</td>
          <td>換成更少選項的維度當第一步</td>
      </tr>
      <tr>
          <td>預設排序看起來「隨機」</td>
          <td>資料來源排序與 UX 不符</td>
          <td>主動 reorder、不接受預設</td>
      </tr>
      <tr>
          <td>新增選項後順序錯亂</td>
          <td>reorder 邏輯依賴 hardcoded list</td>
          <td>改用屬性分類（例如選項數量自動排）</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：UI 排序是設計決策、不是技術選擇。預設順序通常反映資料來源結構、不反映使用者行為 — 主動覆寫是常態、不是例外。</p>
<p>跟 <a href="../view-layer-filter-vs-source-layer/">#55-#66 Filter × Source 系列</a> 的關係：本卡是「filter UI 的排序」、Filter × Source 系列是「filter 行為的層級」 — 兩個維度互補。設計 filter UI 時兩者都要顧：本卡決定「哪個選項放前面」、<a href="../filter-instruction-clarification/">#58</a> 決定「篩選的定義域是哪一層」、<a href="../filter-source-composition-strategies/">#59</a> 決定「filter 跟 source 怎麼合成」。</p>
]]></content:encoded></item><item><title>Mode 與 Facet 是不同語意層級、UI 區域分開擺放</title><link>https://tarrragon.github.io/blog/report/mode-vs-facet-semantics/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/mode-vs-facet-semantics/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Mode（模式）跟 Facet（多面向篩選）是兩個語意層級、UI 區域必須分開。&lt;/strong> Mode 決定「如何搜」、Facet 決定「篩選什麼結果」 — 兩者作用點不同、混在同一 UI 區會讓使用者誤以為是同層的選項、產生「為什麼勾這個結果這麼少」的困惑。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼語意層級要對應視覺分區">為什麼語意層級要對應視覺分區&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>搜尋 UI 包含的控制看起來都是「縮小結果」、語意層級實際不同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>控制類型&lt;/th>
 &lt;th>作用對象&lt;/th>
 &lt;th>改變的東西&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Mode&lt;/td>
 &lt;td>搜尋演算法本身&lt;/td>
 &lt;td>「如何搜」（範圍、方法）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Facet&lt;/td>
 &lt;td>已搜結果集&lt;/td>
 &lt;td>「篩什麼結果」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Mode 在「搜尋」之前生效、Facet 在「搜尋」之後生效 — 兩者在 query pipeline 的位置不同。&lt;/p>
&lt;h3 id="混在一起的失敗模式">混在一起的失敗模式&lt;/h3>
&lt;p>把 mode 跟 facet 放在同一 UI 區域、使用者預設兩者作用相同：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>使用者誤判&lt;/th>
 &lt;th>實際發生&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>勾「標題」是過濾標題欄位（facet）&lt;/td>
 &lt;td>Mode 改變搜尋範圍（標題不含的字會直接 0 結果）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>取消勾「標題」會擴大結果&lt;/td>
 &lt;td>取消等於切回「全部」mode、搜尋邏輯整個換&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者看不出「為什麼結果差異這麼大」 — 因為以為換了一個 facet、實際換了搜尋演算法。&lt;/p>
&lt;h3 id="ui-慣例位置">UI 慣例位置&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>控制類型&lt;/th>
 &lt;th>UI 慣例位置&lt;/th>
 &lt;th>視覺暗示&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Mode&lt;/td>
 &lt;td>緊貼輸入框（旁邊或下方）&lt;/td>
 &lt;td>「跟搜尋本身綁在一起」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Facet&lt;/td>
 &lt;td>結果區附近 / sidebar&lt;/td>
 &lt;td>「在結果上做的事」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>慣例位置反映語意層級 — mode 屬於「query 構造」、facet 屬於「結果處理」。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的應用">這次任務的應用&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁有兩類控制：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>控制&lt;/th>
 &lt;th>類型&lt;/th>
 &lt;th>原本位置&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>搜尋範圍：全部 / 標題 / 內文&lt;/td>
 &lt;td>Mode（影響 regex 比對範圍）&lt;/td>
 &lt;td>一開始想塞進 filter 區&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter：Type / Tag&lt;/td>
 &lt;td>Facet（在已搜結果上篩選）&lt;/td>
 &lt;td>Pagefind 預設 sidebar / drawer&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>把 scope 放進 filter 區會讓使用者把它當第三種 facet — 但「全部 / 標題 / 內文」不是篩選現有結果、是換搜尋範圍：&lt;/p>
&lt;ul>
&lt;li>勾「標題」：搜尋只跑標題欄位、內文有的字直接 0 結果&lt;/li>
&lt;li>取消「標題」（切回「全部」）：搜尋範圍擴大、結果集完全不同&lt;/li>
&lt;/ul>
&lt;p>如果使用者以為這是 facet、預期「取消會稍微多幾個結果」 — 實際結果集大幅變動會困惑。&lt;/p>
&lt;h3 id="執行分區擺放">執行：分區擺放&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>控制&lt;/th>
 &lt;th>最終位置&lt;/th>
 &lt;th>視覺距離&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Scope（mode）&lt;/td>
 &lt;td>搜尋輸入框正下方&lt;/td>
 &lt;td>緊鄰 — 跟 input 視覺綁在一起&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter（facet）&lt;/td>
 &lt;td>左側 sidebar（≥ 1400px）或 pagefind drawer（&amp;lt; 1400px）&lt;/td>
 &lt;td>跟結果區一起&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者視覺上明顯分開、使用者可區分「這是改搜尋方式」vs「這是篩結果」。&lt;/p>
&lt;hr>
&lt;h2 id="內在屬性比較三種-modefacet-擺放">內在屬性比較：三種 mode/facet 擺放&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>擺放策略&lt;/th>
 &lt;th>使用者誤判風險&lt;/th>
 &lt;th>實作成本&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>全部塞同一區（filter）&lt;/td>
 &lt;td>高 — mode 被誤當 facet&lt;/td>
 &lt;td>低 — 不分區&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mode 在 input 旁、Facet 在 sidebar&lt;/td>
 &lt;td>低 — 視覺暗示明確&lt;/td>
 &lt;td>中 — 兩個 slot&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Mode 在 input 旁、Facet 在 sidebar、加說明文字&lt;/td>
 &lt;td>最低 — 雙重提示&lt;/td>
 &lt;td>中 — 多文字&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>優先選「分區 + 視覺暗示」 — 不需要文字說明、靠位置即可辨識。&lt;/p>
&lt;hr>
&lt;h2 id="進階多個-mode-並存的處理">進階：多個 mode 並存的處理&lt;/h2>
&lt;p>當有多個 mode（搜尋範圍 + 排序方式 + 語言）：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Mode 類型&lt;/th>
 &lt;th>慣例位置&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>搜尋範圍&lt;/td>
 &lt;td>input 下方&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>排序方式（相關度 / 日期）&lt;/td>
 &lt;td>結果區頂端&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>語言切換&lt;/td>
 &lt;td>全站 header（最高層級）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每個 mode 跟它影響的範圍視覺鄰近 — 影響搜尋範圍的靠 input、影響結果排序的靠結果。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Mode（模式）跟 Facet（多面向篩選）是兩個語意層級、UI 區域必須分開。</strong> Mode 決定「如何搜」、Facet 決定「篩選什麼結果」 — 兩者作用點不同、混在同一 UI 區會讓使用者誤以為是同層的選項、產生「為什麼勾這個結果這麼少」的困惑。</p>
<hr>
<h2 id="為什麼語意層級要對應視覺分區">為什麼語意層級要對應視覺分區</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>搜尋 UI 包含的控制看起來都是「縮小結果」、語意層級實際不同：</p>
<table>
  <thead>
      <tr>
          <th>控制類型</th>
          <th>作用對象</th>
          <th>改變的東西</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Mode</td>
          <td>搜尋演算法本身</td>
          <td>「如何搜」（範圍、方法）</td>
      </tr>
      <tr>
          <td>Facet</td>
          <td>已搜結果集</td>
          <td>「篩什麼結果」</td>
      </tr>
  </tbody>
</table>
<p>Mode 在「搜尋」之前生效、Facet 在「搜尋」之後生效 — 兩者在 query pipeline 的位置不同。</p>
<h3 id="混在一起的失敗模式">混在一起的失敗模式</h3>
<p>把 mode 跟 facet 放在同一 UI 區域、使用者預設兩者作用相同：</p>
<table>
  <thead>
      <tr>
          <th>使用者誤判</th>
          <th>實際發生</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>勾「標題」是過濾標題欄位（facet）</td>
          <td>Mode 改變搜尋範圍（標題不含的字會直接 0 結果）</td>
      </tr>
      <tr>
          <td>取消勾「標題」會擴大結果</td>
          <td>取消等於切回「全部」mode、搜尋邏輯整個換</td>
      </tr>
  </tbody>
</table>
<p>使用者看不出「為什麼結果差異這麼大」 — 因為以為換了一個 facet、實際換了搜尋演算法。</p>
<h3 id="ui-慣例位置">UI 慣例位置</h3>
<table>
  <thead>
      <tr>
          <th>控制類型</th>
          <th>UI 慣例位置</th>
          <th>視覺暗示</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Mode</td>
          <td>緊貼輸入框（旁邊或下方）</td>
          <td>「跟搜尋本身綁在一起」</td>
      </tr>
      <tr>
          <td>Facet</td>
          <td>結果區附近 / sidebar</td>
          <td>「在結果上做的事」</td>
      </tr>
  </tbody>
</table>
<p>慣例位置反映語意層級 — mode 屬於「query 構造」、facet 屬於「結果處理」。</p>
<hr>
<h2 id="這次任務的應用">這次任務的應用</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁有兩類控制：</p>
<table>
  <thead>
      <tr>
          <th>控制</th>
          <th>類型</th>
          <th>原本位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>搜尋範圍：全部 / 標題 / 內文</td>
          <td>Mode（影響 regex 比對範圍）</td>
          <td>一開始想塞進 filter 區</td>
      </tr>
      <tr>
          <td>Filter：Type / Tag</td>
          <td>Facet（在已搜結果上篩選）</td>
          <td>Pagefind 預設 sidebar / drawer</td>
      </tr>
  </tbody>
</table>
<h3 id="判讀">判讀</h3>
<p>把 scope 放進 filter 區會讓使用者把它當第三種 facet — 但「全部 / 標題 / 內文」不是篩選現有結果、是換搜尋範圍：</p>
<ul>
<li>勾「標題」：搜尋只跑標題欄位、內文有的字直接 0 結果</li>
<li>取消「標題」（切回「全部」）：搜尋範圍擴大、結果集完全不同</li>
</ul>
<p>如果使用者以為這是 facet、預期「取消會稍微多幾個結果」 — 實際結果集大幅變動會困惑。</p>
<h3 id="執行分區擺放">執行：分區擺放</h3>
<table>
  <thead>
      <tr>
          <th>控制</th>
          <th>最終位置</th>
          <th>視覺距離</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Scope（mode）</td>
          <td>搜尋輸入框正下方</td>
          <td>緊鄰 — 跟 input 視覺綁在一起</td>
      </tr>
      <tr>
          <td>Filter（facet）</td>
          <td>左側 sidebar（≥ 1400px）或 pagefind drawer（&lt; 1400px）</td>
          <td>跟結果區一起</td>
      </tr>
  </tbody>
</table>
<p>兩者視覺上明顯分開、使用者可區分「這是改搜尋方式」vs「這是篩結果」。</p>
<hr>
<h2 id="內在屬性比較三種-modefacet-擺放">內在屬性比較：三種 mode/facet 擺放</h2>
<table>
  <thead>
      <tr>
          <th>擺放策略</th>
          <th>使用者誤判風險</th>
          <th>實作成本</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>全部塞同一區（filter）</td>
          <td>高 — mode 被誤當 facet</td>
          <td>低 — 不分區</td>
      </tr>
      <tr>
          <td>Mode 在 input 旁、Facet 在 sidebar</td>
          <td>低 — 視覺暗示明確</td>
          <td>中 — 兩個 slot</td>
      </tr>
      <tr>
          <td>Mode 在 input 旁、Facet 在 sidebar、加說明文字</td>
          <td>最低 — 雙重提示</td>
          <td>中 — 多文字</td>
      </tr>
  </tbody>
</table>
<p>優先選「分區 + 視覺暗示」 — 不需要文字說明、靠位置即可辨識。</p>
<hr>
<h2 id="進階多個-mode-並存的處理">進階：多個 mode 並存的處理</h2>
<p>當有多個 mode（搜尋範圍 + 排序方式 + 語言）：</p>
<table>
  <thead>
      <tr>
          <th>Mode 類型</th>
          <th>慣例位置</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>搜尋範圍</td>
          <td>input 下方</td>
      </tr>
      <tr>
          <td>排序方式（相關度 / 日期）</td>
          <td>結果區頂端</td>
      </tr>
      <tr>
          <td>語言切換</td>
          <td>全站 header（最高層級）</td>
      </tr>
  </tbody>
</table>
<p>每個 mode 跟它影響的範圍視覺鄰近 — 影響搜尋範圍的靠 input、影響結果排序的靠結果。</p>
<p>Facet 也可以分多區（type/tag 在 sidebar、價格區間在結果上方）— 但 mode 與 facet 之間永遠保持區域分離。</p>
<hr>
<h2 id="設計取捨搜尋控制的擺放策略">設計取捨：搜尋控制的擺放策略</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（Mode 靠 input + Facet 靠結果）當預設、其他做法在特定情境合理。</p>
<h3 id="amode-緊貼-input--facet-靠近結果這個專案的預設">A：Mode 緊貼 input + Facet 靠近結果（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：mode 控制（搜尋範圍 / 排序）放 input 旁；facet 控制（type / tag）放結果區或 sidebar</li>
<li><strong>選 A 的理由</strong>：位置就是契約 — 靠 input 的影響搜尋本身、靠結果的篩選結果；使用者靠位置辨識語意層級、不需文字說明</li>
<li><strong>適合</strong>：搜尋 UI、有 mode + facet 兩種控制</li>
<li><strong>代價</strong>：UI 拆兩個區、空間使用多一些</li>
</ul>
<h3 id="b所有控制集中-sidebar">B：所有控制集中 sidebar</h3>
<ul>
<li><strong>機制</strong>：mode + facet 都放 sidebar 一起</li>
<li><strong>跟 A 的取捨</strong>：B 集中管理視覺乾淨、A 分區語意清晰；但 B 使用者區分不了哪個影響 query、哪個影響結果</li>
<li><strong>B 是反模式</strong>：mode/facet 混淆是 UX 痛點 — 使用者區分不了哪個影響 query、哪個影響結果</li>
</ul>
<h3 id="c所有控制集中-input-旁">C：所有控制集中 input 旁</h3>
<ul>
<li><strong>機制</strong>：mode + facet 都靠 input、結果區無控制</li>
<li><strong>跟 A 的取捨</strong>：C 操作集中、A 按語意分；但 C facet 跟結果脫鉤、視覺暗示錯</li>
<li><strong>C 比 A 好的情境</strong>：facet 數量極少（&lt; 3 個）、放 input 旁不擁擠</li>
</ul>
<h3 id="d加文字說明取代位置暗示">D：加文字說明取代位置暗示</h3>
<ul>
<li><strong>機制</strong>：UI 加「此項目影響搜尋演算法」「此項目篩選結果」說明</li>
<li><strong>跟 A 的取捨</strong>：D 文字精確、A 靠位置直覺；但 D 增加閱讀負擔、使用者通常跳過說明</li>
<li><strong>D 比 A 好的情境</strong>：複雜搜尋介面（多種 mode、多層 facet）— 純位置暗示不夠</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>對應問題</th>
          <th>修正動作</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者問「為什麼這個結果出現了」</td>
          <td>mode/facet 混淆</td>
          <td>確認控制類型、分區擺放</td>
      </tr>
      <tr>
          <td>取消某個篩選、結果集大幅變動</td>
          <td>該控制是 mode 不是 facet</td>
          <td>移到 input 旁、視覺區隔</td>
      </tr>
      <tr>
          <td>控制集中在 sidebar、有些影響搜尋有些篩結果</td>
          <td>全部塞 filter 區</td>
          <td>拆出 mode 區、靠 input</td>
      </tr>
      <tr>
          <td>新增搜尋功能不知道該放哪</td>
          <td>沒有分區慣例</td>
          <td>先判斷 mode/facet、再決定位置</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：搜尋 UI 的控制不是同層的「篩選器」 — 語意層級不同、視覺分區也應不同。位置就是契約：靠 input 的影響搜尋本身、靠結果的篩選結果。</p>
<p>跟 <a href="../filter-instruction-clarification/">#58 篩選類指令的澄清時機</a> 的關係：mode 跟 facet 是兩種不同的「篩選類指令」。Mode（如「title-only / content / both」）通常重塑 query → 對應 #58 三問的「定義域 (c) 重新搜尋」；Facet（如「type=post tag=js」）通常加 filter 條件 → 對應「定義域 (b) 在所有結果裡找」。語意分區是視覺面、定義域選擇是行為面 — 兩者一起設計才完整。</p>
]]></content:encoded></item><item><title>Loading / Empty / End 三狀態的區分</title><link>https://tarrragon.github.io/blog/report/loading-empty-end-state-distinction/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/loading-empty-end-state-distinction/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>「Loading」「Empty」「End」是三個語意不同的狀態、UX 必須區分。&lt;/strong> 三者在資料層代表完全不同的事實、使用者根據哪一個決定下一步動作；共用畫面 = 使用者沒辦法決定。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>資料層事實&lt;/th>
 &lt;th>使用者該採取的下一步&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Loading&lt;/td>
 &lt;td>還在抓、結果未知&lt;/td>
 &lt;td>等&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Empty&lt;/td>
 &lt;td>抓完了、確認無命中&lt;/td>
 &lt;td>改 query / 改 filter&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>End&lt;/td>
 &lt;td>抓完了、有結果但無更多&lt;/td>
 &lt;td>看當前結果、不要再 load more&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>混為一談 = 使用者該等的時候改 query、該改 query 的時候等、該停的時候繼續點 load more。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼三狀態容易被混為一談">為什麼三狀態容易被混為一談&lt;/h2>
&lt;h3 id="視覺上類似">視覺上類似&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>常見視覺&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Loading&lt;/td>
 &lt;td>空白 + spinner&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Empty&lt;/td>
 &lt;td>空白 + 「無結果」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>End&lt;/td>
 &lt;td>結果 + 灰掉的按鈕&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Loading 跟 Empty 都是「空白為底」、容易共用畫面。實作時如果只寫 &lt;code>{{ if results }}...{{ else }}&amp;lt;empty /&amp;gt;{{ end }}&lt;/code>、Loading 跟 Empty 會被當成同一件事。&lt;/p>
&lt;h3 id="資料層常常沒提供區分訊號">資料層常常沒提供區分訊號&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">r&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetch&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="nx">showEmpty&lt;/span>&lt;span class="p">();&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>r.length === 0&lt;/code> 只區分有 / 無、不區分「為什麼無」。要區分「還沒抓」vs「抓完無命中」、需要顯式追蹤 fetch 的狀態（pending / done / error），不是看 result。&lt;/p>
&lt;p>End 狀態類似：&lt;code>results.length &amp;gt; 0 &amp;amp;&amp;amp; !hasMore&lt;/code> 才是 End、跟「還可以 load more 的當前結果」不同。&lt;/p>
&lt;hr>
&lt;h2 id="三狀態的可區分訊號">三狀態的可區分訊號&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>必要訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Loading&lt;/td>
 &lt;td>&lt;code>fetchState === 'pending'&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Empty&lt;/td>
 &lt;td>&lt;code>fetchState === 'done' &amp;amp;&amp;amp; results.length === 0&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>End&lt;/td>
 &lt;td>&lt;code>fetchState === 'done' &amp;amp;&amp;amp; results.length &amp;gt; 0 &amp;amp;&amp;amp; !hasMore&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>實作上至少需要：&lt;/p>
&lt;ul>
&lt;li>一個 fetch state machine（不能只看 &lt;code>results&lt;/code>）&lt;/li>
&lt;li>一個「還有沒有下一批」的訊號（&lt;code>hasMore&lt;/code> / cursor / total count）&lt;/li>
&lt;li>UI 對三種組合各畫一個樣子&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="多面向三狀態的延伸">多面向：三狀態的延伸&lt;/h2>
&lt;h3 id="面向-1filter-加進來狀態空間擴張">面向 1：Filter 加進來、狀態空間擴張&lt;/h3>
&lt;p>當 view 層有 filter、三狀態擴張為五狀態（Loading / Empty-raw / Empty-filter / Partial / End）。「Empty-filter」跟「Partial」是 &lt;a href="../view-layer-filter-vs-source-layer/">#55 層錯位&lt;/a> 的 UX 表現 — 共用同個 empty 畫面 = 使用者無法判斷「再 load more 會不會有」。&lt;/p>
&lt;p>具體 UX 模板（三數字、五狀態各別 UI）見 &lt;a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX&lt;/a>。&lt;/p>
&lt;h3 id="面向-2streaming--sse-的無更多很難判斷">面向 2：Streaming / SSE 的「無更多」很難判斷&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="k">of&lt;/span> &lt;span class="nx">eventSource&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">// 跑完了還是斷線了？
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Streaming 通常沒明確的 End 訊號 — 需要 server 主動送一個 &lt;code>event: end&lt;/code>、或 client 用 timeout / heartbeat 判斷。否則使用者看到一段時間沒新資料、不知道是「沒了」還是「還在等」。&lt;/p>
&lt;h3 id="面向-3錯誤狀態應該獨立不混進三狀態">面向 3：錯誤狀態應該獨立、不混進三狀態&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>跟三狀態的關係&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Error&lt;/td>
 &lt;td>獨立第四個狀態、需要不同 UX&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Timeout&lt;/td>
 &lt;td>通常歸 Error&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Offline&lt;/td>
 &lt;td>獨立、需要 retry UX&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>把 Error 顯示成 Empty = 使用者誤以為「沒結果」、不會 retry。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>「Loading」「Empty」「End」是三個語意不同的狀態、UX 必須區分。</strong> 三者在資料層代表完全不同的事實、使用者根據哪一個決定下一步動作；共用畫面 = 使用者沒辦法決定。</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>資料層事實</th>
          <th>使用者該採取的下一步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading</td>
          <td>還在抓、結果未知</td>
          <td>等</td>
      </tr>
      <tr>
          <td>Empty</td>
          <td>抓完了、確認無命中</td>
          <td>改 query / 改 filter</td>
      </tr>
      <tr>
          <td>End</td>
          <td>抓完了、有結果但無更多</td>
          <td>看當前結果、不要再 load more</td>
      </tr>
  </tbody>
</table>
<p>混為一談 = 使用者該等的時候改 query、該改 query 的時候等、該停的時候繼續點 load more。</p>
<hr>
<h2 id="為什麼三狀態容易被混為一談">為什麼三狀態容易被混為一談</h2>
<h3 id="視覺上類似">視覺上類似</h3>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>常見視覺</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading</td>
          <td>空白 + spinner</td>
      </tr>
      <tr>
          <td>Empty</td>
          <td>空白 + 「無結果」</td>
      </tr>
      <tr>
          <td>End</td>
          <td>結果 + 灰掉的按鈕</td>
      </tr>
  </tbody>
</table>
<p>Loading 跟 Empty 都是「空白為底」、容易共用畫面。實作時如果只寫 <code>{{ if results }}...{{ else }}&lt;empty /&gt;{{ end }}</code>、Loading 跟 Empty 會被當成同一件事。</p>
<h3 id="資料層常常沒提供區分訊號">資料層常常沒提供區分訊號</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">r</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">r</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="nx">showEmpty</span><span class="p">();</span></span></span></code></pre></div><p><code>r.length === 0</code> 只區分有 / 無、不區分「為什麼無」。要區分「還沒抓」vs「抓完無命中」、需要顯式追蹤 fetch 的狀態（pending / done / error），不是看 result。</p>
<p>End 狀態類似：<code>results.length &gt; 0 &amp;&amp; !hasMore</code> 才是 End、跟「還可以 load more 的當前結果」不同。</p>
<hr>
<h2 id="三狀態的可區分訊號">三狀態的可區分訊號</h2>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>必要訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading</td>
          <td><code>fetchState === 'pending'</code></td>
      </tr>
      <tr>
          <td>Empty</td>
          <td><code>fetchState === 'done' &amp;&amp; results.length === 0</code></td>
      </tr>
      <tr>
          <td>End</td>
          <td><code>fetchState === 'done' &amp;&amp; results.length &gt; 0 &amp;&amp; !hasMore</code></td>
      </tr>
  </tbody>
</table>
<p>實作上至少需要：</p>
<ul>
<li>一個 fetch state machine（不能只看 <code>results</code>）</li>
<li>一個「還有沒有下一批」的訊號（<code>hasMore</code> / cursor / total count）</li>
<li>UI 對三種組合各畫一個樣子</li>
</ul>
<hr>
<h2 id="多面向三狀態的延伸">多面向：三狀態的延伸</h2>
<h3 id="面向-1filter-加進來狀態空間擴張">面向 1：Filter 加進來、狀態空間擴張</h3>
<p>當 view 層有 filter、三狀態擴張為五狀態（Loading / Empty-raw / Empty-filter / Partial / End）。「Empty-filter」跟「Partial」是 <a href="../view-layer-filter-vs-source-layer/">#55 層錯位</a> 的 UX 表現 — 共用同個 empty 畫面 = 使用者無法判斷「再 load more 會不會有」。</p>
<p>具體 UX 模板（三數字、五狀態各別 UI）見 <a href="../pattern-honest-progress-ui/">#62 Pattern：誠實進度 UX</a>。</p>
<h3 id="面向-2streaming--sse-的無更多很難判斷">面向 2：Streaming / SSE 的「無更多」很難判斷</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> <span class="kr">await</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">item</span> <span class="k">of</span> <span class="nx">eventSource</span><span class="p">)</span> <span class="p">{</span> <span class="p">...</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 跑完了還是斷線了？
</span></span></span></code></pre></div><p>Streaming 通常沒明確的 End 訊號 — 需要 server 主動送一個 <code>event: end</code>、或 client 用 timeout / heartbeat 判斷。否則使用者看到一段時間沒新資料、不知道是「沒了」還是「還在等」。</p>
<h3 id="面向-3錯誤狀態應該獨立不混進三狀態">面向 3：錯誤狀態應該獨立、不混進三狀態</h3>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>跟三狀態的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Error</td>
          <td>獨立第四個狀態、需要不同 UX</td>
      </tr>
      <tr>
          <td>Timeout</td>
          <td>通常歸 Error</td>
      </tr>
      <tr>
          <td>Offline</td>
          <td>獨立、需要 retry UX</td>
      </tr>
  </tbody>
</table>
<p>把 Error 顯示成 Empty = 使用者誤以為「沒結果」、不會 retry。</p>
<hr>
<h2 id="設計取捨ux-該怎麼呈現三狀態">設計取捨：UX 該怎麼呈現三狀態</h2>
<h3 id="a每個狀態獨立的-ui-元件">A：每個狀態獨立的 UI 元件</h3>
<ul>
<li><strong>機制</strong>：Loading 顯示 spinner、Empty 顯示 illustration + 「改 query」CTA、End 顯示「all results loaded」、Error 顯示 retry button</li>
<li><strong>選 A 的理由</strong>：四個狀態語意完全清楚、使用者下一步明確</li>
<li><strong>代價</strong>：UI 元件多、設計成本高</li>
</ul>
<h3 id="b用文字--細節區分共用-layout">B：用文字 + 細節區分、共用 layout</h3>
<ul>
<li><strong>機制</strong>：同一個 container、不同狀態填不同文字（&ldquo;Loading&hellip;&rdquo; / &ldquo;No results for X&rdquo; / &ldquo;Showing all 23 results&rdquo;）</li>
<li><strong>跟 A 的取捨</strong>：B 設計簡單、但區分性弱（使用者要讀文字才知道狀態）</li>
<li><strong>B 才合理的情境</strong>：簡單 UI、使用者願意讀文字</li>
</ul>
<h3 id="c只用視覺-cuespinner--空白">C：只用視覺 cue（spinner / 空白）</h3>
<ul>
<li><strong>機制</strong>：spinner = loading、空白 = 沒結果、結果列表 = 有</li>
<li><strong>跟 A 的取捨</strong>：C 沒區分 Empty vs End vs Partial</li>
<li><strong>C 才合理的情境</strong>：source 沒分批、結果一次給完</li>
</ul>
<h3 id="d完全不區分三狀態反模式">D：完全不區分三狀態（反模式）</h3>
<ul>
<li><strong>為什麼是反模式</strong>：把「使用者下一步該做什麼」這個決策丟給使用者自己猜、違反「UI 必須回答下一步問題」原則</li>
<li><strong>看起來吸引人的原因</strong>：UI 寫起來最簡單、不用畫 Loading / Empty / End 三版、<code>{{ if results }}...{{ else }}empty{{ end }}</code> 一行解決</li>
<li><strong>實際發生的代價</strong>：使用者操作不知所措、support tickets 增加、使用者信任損失（「這網站到底有沒有在 load」）</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UI 寫 <code>{{ if results }}...{{ else }}&lt;empty /&gt;{{ end }}</code></td>
          <td>補：Loading / Error / End / Partial 各一個分支</td>
      </tr>
      <tr>
          <td>沒有 <code>fetchState</code> / <code>hasMore</code> 變數</td>
          <td>加 — 否則無法區分三狀態</td>
      </tr>
      <tr>
          <td>Empty UI 上沒有「下一步該做什麼」的 CTA</td>
          <td>補：「改 query」「reset filter」「retry」等行動建議</td>
      </tr>
      <tr>
          <td>Loading 共用 Empty 畫面（都是空白）</td>
          <td>加區分（spinner vs 文字）</td>
      </tr>
      <tr>
          <td>Streaming / async iterator 沒明確 End 訊號</td>
          <td>加：server-side 送 end event、或 client timeout</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：三狀態（Loading / Empty / End）是不同事實、不同 UX。共用畫面 = 把「使用者該做什麼」這個決策丟給使用者自己猜。實作要從資料層追蹤 state、不能只看 <code>results</code>。</p>
<p>跟 <a href="../aria-live-for-dynamic-content/">#38 動態內容變動的 aria-live region 設計</a> 同源：兩者都是「狀態變動需要告知使用者」、本卡告訴的是 sighted 使用者（視覺區分）、#38 告訴 screen reader（aria-live 廣播）。</p>
]]></content:encoded></item><item><title>Pattern：誠實進度 UX（已掃 N / 命中 K / 共 M）</title><link>https://tarrragon.github.io/blog/report/pattern-honest-progress-ui/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-honest-progress-ui/</guid><description>&lt;h2 id="pattern-一句話">Pattern 一句話&lt;/h2>
&lt;p>當 filter 必然有層錯位、用「已掃 N / 命中 K / 共 M」三數字 + 「再掃一批」按鈕讓使用者看見掃描範圍、自己決定要不要續抓。&lt;/p>
&lt;p>對應 #59 &lt;a href="../filter-source-composition-strategies/">Filter × Source 合成策略&lt;/a> 的策略 D。&lt;/p>
&lt;hr>
&lt;h2 id="何時用何時不用">何時用、何時不用&lt;/h2>
&lt;h3 id="用">用&lt;/h3>
&lt;ul>
&lt;li>Source 不支援 server-side filter（A 不可行）&lt;/li>
&lt;li>不能或不值得重 index（C 不可行）&lt;/li>
&lt;li>Match 稀疏或不可預期、自動續抓（B）會拉爆&lt;/li>
&lt;li>工程量限制、原型期 / MVP&lt;/li>
&lt;/ul>
&lt;h3 id="不用">不用&lt;/h3>
&lt;ul>
&lt;li>Filter 是主要互動模式（使用者預期「自動全找完」）&lt;/li>
&lt;li>三數字會讓 UI 太複雜&lt;/li>
&lt;li>使用者完全不在意「掃描範圍」&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="三數字的語意">三數字的語意&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>數字&lt;/th>
 &lt;th>意思&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>已掃 N&lt;/td>
 &lt;td>已從 source 載入並 filter 過的筆數&lt;/td>
 &lt;td>client 累計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>命中 K&lt;/td>
 &lt;td>已掃 N 筆中、符合 filter 的筆數&lt;/td>
 &lt;td>client 累計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>共 M&lt;/td>
 &lt;td>Source 總筆數（如果 source 知道）&lt;/td>
 &lt;td>source meta（可選）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>最少要顯示「已掃 N / 命中 K」 — 沒有 N 使用者不知道掃描範圍、沒有 K 使用者不知道有沒有命中。&lt;/p>
&lt;p>「共 M」可選 — 有的 source（pagefind）會給 total count、有的（streaming）不會。&lt;/p>
&lt;hr>
&lt;h2 id="ui-模板">UI 模板&lt;/h2>
&lt;h3 id="基本版">基本版&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;filter-status&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> 已掃 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>24&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> 筆 / 命中 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>3&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> 筆
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>再掃一批&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="含總數">含總數&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;filter-status&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> 已掃 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>24&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> / &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>~150&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> 筆 — 命中 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>3&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>再掃一批&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="含結束狀態呼應-57-三狀態">含結束狀態（呼應 #57 三狀態）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Loading --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;filter-status&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>掃描中... 已掃 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>24&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> / 命中 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>3&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Partial（還可續） --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;filter-status&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>已掃 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>24&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> / 命中 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>3&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>再掃一批&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- End（掃完） --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;filter-status&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>已全部掃完、共命中 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>12&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> 筆&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="c">&amp;lt;!-- Empty (filter) --&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;filter-status&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>已掃 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>24&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">strong&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>、沒有命中
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>再掃一批&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span> 或 &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">a&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>清除 filter&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">a&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="進度更新時機">進度更新時機&lt;/h2>
&lt;h3 id="即時更新每筆">即時更新（每筆）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="kr">const&lt;/span> &lt;span class="nx">item&lt;/span> &lt;span class="k">of&lt;/span> &lt;span class="nx">stream&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">scanned&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">matched&lt;/span>&lt;span class="o">++&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">appendResult&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">item&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="nx">updateUI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scanned&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">matched&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 每筆更新
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>UX 順、但 DOM 操作頻繁、可能 jank。&lt;/p>
&lt;h3 id="批次更新每批">批次更新（每批）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">batch&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetchNext&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="nx">scanned&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">m&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">batch&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">matches&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="nx">matched&lt;/span> &lt;span class="o">+=&lt;/span> &lt;span class="nx">m&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="nx">appendResults&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">m&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="nx">updateUI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scanned&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">matched&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 每批一次
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>DOM 操作少、但 UX 不夠順（一段時間沒動）。&lt;/p>
&lt;h3 id="推薦每批--載入中-spinner">推薦：每批 + 載入中 spinner&lt;/h3>
&lt;p>批次後更新數字、批次間顯示 spinner。最平衡。&lt;/p>
&lt;hr>
&lt;h2 id="跟自動續抓b的混合">跟自動續抓（B）的混合&lt;/h2>
&lt;p>可以做成「初始自動續抓 N 批、之後切誠實 UX」：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">searchWithFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">query&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 初始自動續抓 3 批（湊一些結果）
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">fetchUntilQuota&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">autoBatches&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">3&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 之後使用者手動點「再掃一批」
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nx">showHonestProgressUI&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>混合的好處：使用者一進來就有結果（不是空畫面）、之後續抓由使用者決定。&lt;/p></description><content:encoded><![CDATA[<h2 id="pattern-一句話">Pattern 一句話</h2>
<p>當 filter 必然有層錯位、用「已掃 N / 命中 K / 共 M」三數字 + 「再掃一批」按鈕讓使用者看見掃描範圍、自己決定要不要續抓。</p>
<p>對應 #59 <a href="../filter-source-composition-strategies/">Filter × Source 合成策略</a> 的策略 D。</p>
<hr>
<h2 id="何時用何時不用">何時用、何時不用</h2>
<h3 id="用">用</h3>
<ul>
<li>Source 不支援 server-side filter（A 不可行）</li>
<li>不能或不值得重 index（C 不可行）</li>
<li>Match 稀疏或不可預期、自動續抓（B）會拉爆</li>
<li>工程量限制、原型期 / MVP</li>
</ul>
<h3 id="不用">不用</h3>
<ul>
<li>Filter 是主要互動模式（使用者預期「自動全找完」）</li>
<li>三數字會讓 UI 太複雜</li>
<li>使用者完全不在意「掃描範圍」</li>
</ul>
<hr>
<h2 id="三數字的語意">三數字的語意</h2>
<table>
  <thead>
      <tr>
          <th>數字</th>
          <th>意思</th>
          <th>來源</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>已掃 N</td>
          <td>已從 source 載入並 filter 過的筆數</td>
          <td>client 累計</td>
      </tr>
      <tr>
          <td>命中 K</td>
          <td>已掃 N 筆中、符合 filter 的筆數</td>
          <td>client 累計</td>
      </tr>
      <tr>
          <td>共 M</td>
          <td>Source 總筆數（如果 source 知道）</td>
          <td>source meta（可選）</td>
      </tr>
  </tbody>
</table>
<p>最少要顯示「已掃 N / 命中 K」 — 沒有 N 使用者不知道掃描範圍、沒有 K 使用者不知道有沒有命中。</p>
<p>「共 M」可選 — 有的 source（pagefind）會給 total count、有的（streaming）不會。</p>
<hr>
<h2 id="ui-模板">UI 模板</h2>
<h3 id="基本版">基本版</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆 / 命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>再掃一批<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><h3 id="含總數">含總數</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> / <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>~150<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆 — 命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>再掃一批<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><h3 id="含結束狀態呼應-57-三狀態">含結束狀態（呼應 #57 三狀態）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c">&lt;!-- Loading --&gt;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>掃描中... 已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> / 命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c">&lt;!-- Partial（還可續） --&gt;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> / 命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>3<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>再掃一批<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c">&lt;!-- End（掃完） --&gt;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>已全部掃完、共命中 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>12<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span> 筆<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c">&lt;!-- Empty (filter) --&gt;</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;filter-status&#34;</span><span class="p">&gt;</span>已掃 <span class="p">&lt;</span><span class="nt">strong</span><span class="p">&gt;</span>24<span class="p">&lt;/</span><span class="nt">strong</span><span class="p">&gt;</span>、沒有命中
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="p">&lt;</span><span class="nt">button</span><span class="p">&gt;</span>再掃一批<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span> 或 <span class="p">&lt;</span><span class="nt">a</span><span class="p">&gt;</span>清除 filter<span class="p">&lt;/</span><span class="nt">a</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><hr>
<h2 id="進度更新時機">進度更新時機</h2>
<h3 id="即時更新每筆">即時更新（每筆）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">item</span> <span class="k">of</span> <span class="nx">stream</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">scanned</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">matches</span><span class="p">(</span><span class="nx">item</span><span class="p">))</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">matched</span><span class="o">++</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">appendResult</span><span class="p">(</span><span class="nx">item</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nx">updateUI</span><span class="p">(</span><span class="nx">scanned</span><span class="p">,</span> <span class="nx">matched</span><span class="p">);</span>  <span class="c1">// 每筆更新
</span></span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>UX 順、但 DOM 操作頻繁、可能 jank。</p>
<h3 id="批次更新每批">批次更新（每批）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">batch</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetchNext</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="nx">scanned</span> <span class="o">+=</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="kr">const</span> <span class="nx">m</span> <span class="o">=</span> <span class="nx">batch</span><span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">matches</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="nx">matched</span> <span class="o">+=</span> <span class="nx">m</span><span class="p">.</span><span class="nx">length</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="nx">appendResults</span><span class="p">(</span><span class="nx">m</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="nx">updateUI</span><span class="p">(</span><span class="nx">scanned</span><span class="p">,</span> <span class="nx">matched</span><span class="p">);</span>  <span class="c1">// 每批一次
</span></span></span></code></pre></div><p>DOM 操作少、但 UX 不夠順（一段時間沒動）。</p>
<h3 id="推薦每批--載入中-spinner">推薦：每批 + 載入中 spinner</h3>
<p>批次後更新數字、批次間顯示 spinner。最平衡。</p>
<hr>
<h2 id="跟自動續抓b的混合">跟自動續抓（B）的混合</h2>
<p>可以做成「初始自動續抓 N 批、之後切誠實 UX」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">searchWithFilter</span><span class="p">(</span><span class="nx">query</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="c1">// 初始自動續抓 3 批（湊一些結果）
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="kr">await</span> <span class="nx">fetchUntilQuota</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="nx">autoBatches</span><span class="o">:</span> <span class="mi">3</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="c1">// 之後使用者手動點「再掃一批」
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="nx">showHonestProgressUI</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>混合的好處：使用者一進來就有結果（不是空畫面）、之後續抓由使用者決定。</p>
<hr>
<h2 id="反例">反例</h2>
<h3 id="反例-1只顯示命中-k不顯示已掃-n">反例 1：只顯示「命中 K」、不顯示「已掃 N」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">div</span><span class="p">&gt;</span>找到 3 筆結果<span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</span></span></span></code></pre></div><p>使用者不知道是從多少筆裡找的、不知道「再掃會不會有」。</p>
<h3 id="反例-2只顯示共-m--n進度條沒分已掃命中">反例 2：只顯示「共 M / N」進度條、沒分「已掃」「命中」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">progress</span> <span class="na">value</span><span class="o">=</span><span class="s">&#34;24&#34;</span> <span class="na">max</span><span class="o">=</span><span class="s">&#34;150&#34;</span><span class="p">&gt;&lt;/</span><span class="nt">progress</span><span class="p">&gt;</span></span></span></code></pre></div><p>進度條告訴使用者「load 進度」、但「load 進度 ≠ filter 進度」。沒命中時使用者不知道為什麼進度走了 24% 但畫面沒結果。</p>
<h3 id="反例-3再掃一批沒做">反例 3：「再掃一批」沒做</h3>
<p>只顯示三數字、沒提供續抓 button — 使用者看到「已掃 24 沒命中」、不知道下一步。</p>
<hr>
<h2 id="跟-57-三狀態的關係">跟 #57 三狀態的關係</h2>
<p>誠實進度 UX 是 #57 <a href="../loading-empty-end-state-distinction/">Loading / Empty / End 三狀態的區分</a> 在「filter + 分批」情境下的具體實作。三數字提供區分三狀態的訊號：</p>
<table>
  <thead>
      <tr>
          <th>#57 狀態</th>
          <th>對應的三數字組合</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Loading</td>
          <td>已掃增加中、N 還在跑</td>
      </tr>
      <tr>
          <td>Empty (filter)</td>
          <td>已掃 = 24、命中 = 0、還有 → 「再掃」</td>
      </tr>
      <tr>
          <td>End</td>
          <td>已掃 = M、命中 = K（K 可能 0）</td>
      </tr>
      <tr>
          <td>Partial</td>
          <td>已掃 &lt; M、命中 ≥ 1、還有 → 「再掃」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter 後可能 0 筆、source 還有未載入</td>
          <td>用本 pattern</td>
      </tr>
      <tr>
          <td>UI 上只有「找到 K 筆」、沒有「已掃 N」</td>
          <td>補 N — 否則使用者無法判斷</td>
      </tr>
      <tr>
          <td>沒有「再掃一批」按鈕</td>
          <td>補 — 給使用者下一步行動</td>
      </tr>
      <tr>
          <td>工程量允許做策略 A / C</td>
          <td>用 A / C、誠實 UX 是退路</td>
      </tr>
      <tr>
          <td>Match 密集、自動續抓不會爆</td>
          <td>用策略 B、誠實 UX 太顯眼</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：誠實 UX 不是「lazy 解法」、是「sourcing 限制下的合理透明度」。給使用者三數字 + 行動選項、比假裝完美但 silent 失敗好。</p>
<p>跟 <a href="../override-depth-cost-report/">#19 覆寫深度的成本告知</a> 同源：兩者都是「把實作的限制 / 代價攤給使用者、讓使用者參與決策」。差別在 #19 是「實作前告知工程成本」、本卡是「runtime 持續顯示掃描成本」 — 攤出來的位置不同、原則一致：silent 累積負擔是反模式。</p>
]]></content:encoded></item><item><title>Pattern：明示語意縮小（不承諾全集）</title><link>https://tarrragon.github.io/blog/report/pattern-explicit-semantic-narrowing/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/pattern-explicit-semantic-narrowing/</guid><description>&lt;h2 id="pattern-一句話">Pattern 一句話&lt;/h2>
&lt;p>當 filter 必然只在已載入子集上運作、用 UI 文字 / API contract / docstring 明確告訴呼叫者「範圍 = 已載入、不承諾全集」 — 不假裝是全集 filter。&lt;/p>
&lt;p>對應 #59 &lt;a href="../filter-source-composition-strategies/">Filter × Source 合成策略&lt;/a> 的策略 E。&lt;/p>
&lt;hr>
&lt;h2 id="何時用何時不用">何時用、何時不用&lt;/h2>
&lt;h3 id="用">用&lt;/h3>
&lt;ul>
&lt;li>Source 不支援推進 query (A 不可行)&lt;/li>
&lt;li>不能控 build pipeline (C 不可行)&lt;/li>
&lt;li>Match 稀疏、自動續抓會拉爆 (B 不可行)&lt;/li>
&lt;li>工程量限制、做不了 #62 誠實 UX 的三數字&lt;/li>
&lt;li>能接受「filter 範圍 = subset」這個語意縮小、但要使用者知道&lt;/li>
&lt;/ul>
&lt;h3 id="不用">不用&lt;/h3>
&lt;ul>
&lt;li>Source 一次給完整 dataset（沒有 subset、不需要縮小）&lt;/li>
&lt;li>使用者預期 filter 是「全集」、無法接受縮小&lt;/li>
&lt;li>應用情境影響重大決策（finance、medical 等不能接受 silent 範圍縮小）&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="跟策略-d誠實-ux的差別">跟策略 D（誠實 UX）的差別&lt;/h2>
&lt;p>D 跟 E 都是「在 subset 上 filter」、差別在「怎麼告訴使用者」：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>面向&lt;/th>
 &lt;th>D（誠實 UX）&lt;/th>
 &lt;th>E（明示語意縮小）&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>範圍訊號&lt;/td>
 &lt;td>即時數字（已掃 N / 命中 K）&lt;/td>
 &lt;td>文字描述（一次性告知）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UI 顯眼度&lt;/td>
 &lt;td>高 — 每次都看得到&lt;/td>
 &lt;td>低 — 看一次就過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>工程量&lt;/td>
 &lt;td>中 — 要實作三數字&lt;/td>
 &lt;td>低 — 改文字 / 加 docstring&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者參與&lt;/td>
 &lt;td>點「再掃一批」續抓&lt;/td>
 &lt;td>不續抓、自己判斷要不要 load more&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>適合&lt;/td>
 &lt;td>filter 是主要互動模式&lt;/td>
 &lt;td>filter 是次要功能、原型期&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>簡言之：D 是「持續顯示掃描範圍」、E 是「告訴一次、之後不再提」。&lt;/p>
&lt;hr>
&lt;h2 id="明示的具體做法">「明示」的具體做法&lt;/h2>
&lt;h3 id="ui-明示">UI 明示&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">input&lt;/span> &lt;span class="na">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;search&amp;#34;&lt;/span> &lt;span class="na">placeholder&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;Filter loaded results...&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">small&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;hint&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>只在已載入的結果裡篩選。要看更多請先載入更多。&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">small&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>「Filter loaded results」、「已載入的結果裡」、「載入更多」 — 三個 cue 讓使用者知道範圍。&lt;/p>
&lt;h3 id="api-contract-明示">API contract 明示&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="cm">/**
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="cm"> * Filter loaded results by predicate.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="cm"> *
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="cm"> * NOTE: Operates on currently loaded subset only.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="cm"> * Does NOT trigger fetch of un-loaded items. To filter the full
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl">&lt;span class="cm"> * dataset, use {@link searchAll} instead.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="cm"> */&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="kd">function&lt;/span> &lt;span class="nx">filterLoaded&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">predicate&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">item&lt;/span>: &lt;span class="kt">Item&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="kr">boolean&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">Item&lt;/span>&lt;span class="p">[];&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>JSDoc / TSDoc 把語意寫進 API、IDE 提示能看到。&lt;/p>
&lt;h3 id="docstring--readme-明示">Docstring / README 明示&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-markdown" data-lang="markdown">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="gu">## Filter behavior
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="gu">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="sb">`filter()`&lt;/span> only operates on results currently loaded in client.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">If the source uses pagination, items not yet loaded are NOT included.
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">For full-dataset filtering, the source must support server-side filter.&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>文件級的明示、給開發者讀。&lt;/p></description><content:encoded><![CDATA[<h2 id="pattern-一句話">Pattern 一句話</h2>
<p>當 filter 必然只在已載入子集上運作、用 UI 文字 / API contract / docstring 明確告訴呼叫者「範圍 = 已載入、不承諾全集」 — 不假裝是全集 filter。</p>
<p>對應 #59 <a href="../filter-source-composition-strategies/">Filter × Source 合成策略</a> 的策略 E。</p>
<hr>
<h2 id="何時用何時不用">何時用、何時不用</h2>
<h3 id="用">用</h3>
<ul>
<li>Source 不支援推進 query (A 不可行)</li>
<li>不能控 build pipeline (C 不可行)</li>
<li>Match 稀疏、自動續抓會拉爆 (B 不可行)</li>
<li>工程量限制、做不了 #62 誠實 UX 的三數字</li>
<li>能接受「filter 範圍 = subset」這個語意縮小、但要使用者知道</li>
</ul>
<h3 id="不用">不用</h3>
<ul>
<li>Source 一次給完整 dataset（沒有 subset、不需要縮小）</li>
<li>使用者預期 filter 是「全集」、無法接受縮小</li>
<li>應用情境影響重大決策（finance、medical 等不能接受 silent 範圍縮小）</li>
</ul>
<hr>
<h2 id="跟策略-d誠實-ux的差別">跟策略 D（誠實 UX）的差別</h2>
<p>D 跟 E 都是「在 subset 上 filter」、差別在「怎麼告訴使用者」：</p>
<table>
  <thead>
      <tr>
          <th>面向</th>
          <th>D（誠實 UX）</th>
          <th>E（明示語意縮小）</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>範圍訊號</td>
          <td>即時數字（已掃 N / 命中 K）</td>
          <td>文字描述（一次性告知）</td>
      </tr>
      <tr>
          <td>UI 顯眼度</td>
          <td>高 — 每次都看得到</td>
          <td>低 — 看一次就過</td>
      </tr>
      <tr>
          <td>工程量</td>
          <td>中 — 要實作三數字</td>
          <td>低 — 改文字 / 加 docstring</td>
      </tr>
      <tr>
          <td>使用者參與</td>
          <td>點「再掃一批」續抓</td>
          <td>不續抓、自己判斷要不要 load more</td>
      </tr>
      <tr>
          <td>適合</td>
          <td>filter 是主要互動模式</td>
          <td>filter 是次要功能、原型期</td>
      </tr>
  </tbody>
</table>
<p>簡言之：D 是「持續顯示掃描範圍」、E 是「告訴一次、之後不再提」。</p>
<hr>
<h2 id="明示的具體做法">「明示」的具體做法</h2>
<h3 id="ui-明示">UI 明示</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;search&#34;</span> <span class="na">placeholder</span><span class="o">=</span><span class="s">&#34;Filter loaded results...&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">&lt;</span><span class="nt">small</span> <span class="na">class</span><span class="o">=</span><span class="s">&#34;hint&#34;</span><span class="p">&gt;</span>只在已載入的結果裡篩選。要看更多請先載入更多。<span class="p">&lt;/</span><span class="nt">small</span><span class="p">&gt;</span></span></span></code></pre></div><p>「Filter loaded results」、「已載入的結果裡」、「載入更多」 — 三個 cue 讓使用者知道範圍。</p>
<h3 id="api-contract-明示">API contract 明示</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="ln">1</span><span class="cl"><span class="cm">/**
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cm"> * Filter loaded results by predicate.
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="cm"> *
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="cm"> * NOTE: Operates on currently loaded subset only.
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="cm"> * Does NOT trigger fetch of un-loaded items. To filter the full
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="cm"> * dataset, use {@link searchAll} instead.
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="cm"> */</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="kd">function</span> <span class="nx">filterLoaded</span><span class="p">(</span><span class="nx">predicate</span><span class="o">:</span> <span class="p">(</span><span class="nx">item</span>: <span class="kt">Item</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">boolean</span><span class="p">)</span><span class="o">:</span> <span class="nx">Item</span><span class="p">[];</span></span></span></code></pre></div><p>JSDoc / TSDoc 把語意寫進 API、IDE 提示能看到。</p>
<h3 id="docstring--readme-明示">Docstring / README 明示</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="ln">1</span><span class="cl"><span class="gu">## Filter behavior
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="gu"></span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="sb">`filter()`</span> only operates on results currently loaded in client.
</span></span><span class="line"><span class="ln">4</span><span class="cl">If the source uses pagination, items not yet loaded are NOT included.
</span></span><span class="line"><span class="ln">5</span><span class="cl">For full-dataset filtering, the source must support server-side filter.</span></span></code></pre></div><p>文件級的明示、給開發者讀。</p>
<hr>
<h2 id="反例">反例</h2>
<h3 id="反例-1silent-縮小不告訴">反例 1：Silent 縮小（不告訴）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">input</span> <span class="na">type</span><span class="o">=</span><span class="s">&#34;search&#34;</span> <span class="na">placeholder</span><span class="o">=</span><span class="s">&#34;Filter results...&#34;</span><span class="p">&gt;</span></span></span></code></pre></div><p>「Filter results」沒指明「only loaded」 — 使用者預設是全集 filter、實際是 subset → 撞回 #55 <a href="../view-layer-filter-vs-source-layer/">層錯位</a> 的語意縫。</p>
<h3 id="反例-2明示位置使用者看不到">反例 2：明示位置使用者看不到</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="ln">1</span><span class="cl"><span class="cm">/**
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="cm"> * Filter results.
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="cm"> * Note: subset only.
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="cm"> */</span></span></span></code></pre></div><p>使用者只看 UI、不讀 docstring — 「明示」要在使用者會看到的位置（UI hint、tooltip、行為描述）。</p>
<h3 id="反例-3明示但不清楚">反例 3：明示但不清楚</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">small</span><span class="p">&gt;</span>限定範圍篩選<span class="p">&lt;/</span><span class="nt">small</span><span class="p">&gt;</span></span></span></code></pre></div><p>「限定範圍」太抽象、沒說明是什麼範圍。要寫具體：「已載入的 N 筆內」「不包含尚未載入的」。</p>
<hr>
<h2 id="何時-e-升級到-d">何時 E 升級到 D</h2>
<p>當以下任一觸發、把 E 升級到 D（誠實 UX 三數字）：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>行動</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>使用者依然誤以為是全集 filter</td>
          <td>升 D — 文字明示不夠</td>
      </tr>
      <tr>
          <td>Filter 後 0 筆的情境變常見</td>
          <td>升 D — 三數字能 disambiguate</td>
      </tr>
      <tr>
          <td>Filter 變主要互動模式（不再是次要功能）</td>
          <td>升 D — 顯眼度需要拉高</td>
      </tr>
      <tr>
          <td>Match 密度高、續抓 ROI 變正</td>
          <td>升 B（自動續抓）</td>
      </tr>
  </tbody>
</table>
<p>E 是「成本低的退路」、不是長期解。當需求成熟、應該升級到 D / A / C。</p>
<hr>
<h2 id="跟其他-pattern-的關係">跟其他 Pattern 的關係</h2>
<ul>
<li>E 是策略順序 A → C → B → D 之外的「最後退路」</li>
<li>E 跟 D 都是「在 subset 上做」、差別在告知方式</li>
<li>E 跟 #55 silent 反模式的差別：<strong>E 是 explicit 縮小、silent 是 implicit 縮小</strong></li>
</ul>
<p>選擇順序（重申）：<strong>A 推進 → C 多 index → B 自動續抓 → D 誠實 UX → E 明示縮小 → silent（反模式）</strong></p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Source 不支援、工程量做不了 D</td>
          <td>用本 pattern</td>
      </tr>
      <tr>
          <td>Filter 行為已決定是 subset、但 UI 沒寫</td>
          <td>補 UI hint</td>
      </tr>
      <tr>
          <td>API 沒 docstring 說明 filter 範圍</td>
          <td>補 docstring</td>
      </tr>
      <tr>
          <td>使用者反映「filter 結果跟我想的不一樣」</td>
          <td>E 沒成功、升級到 D 或 A</td>
      </tr>
      <tr>
          <td>內心 OS：「反正 subset 就是 subset、寫了也沒人看」</td>
          <td>停 — silent 縮小是 #55 反模式</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：能接受語意縮小是可以、但必須明示。Silent 縮小（沒告知就 subset）等於 #55 層錯位、是反模式。E 的價值在「明示」這個動作、不在「subset」這個事實。</p>
]]></content:encoded></item><item><title>URL 是 stateful UI 的儲存層 — 哪些 state 該寫進 URL</title><link>https://tarrragon.github.io/blog/report/url-as-state-container/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/url-as-state-container/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;blockquote>
&lt;p>State 的儲存層決定它的特性 — 可分享 / 可恢復 / 可導航 的 state 該寫進 URL、不寫進 = silent 把這些特性犧牲掉。&lt;/p>&lt;/blockquote>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>儲存層&lt;/th>
 &lt;th>可分享&lt;/th>
 &lt;th>可 reload 恢復&lt;/th>
 &lt;th>可 back/forward 導航&lt;/th>
 &lt;th>跨 tab 同步&lt;/th>
 &lt;th>跨 device 同步&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>In-memory&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>URL&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>部分（同 URL）&lt;/td>
 &lt;td>部分（複製連結）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>sessionStorage&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>localStorage&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是（同 origin）&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Server&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>否&lt;/td>
 &lt;td>是&lt;/td>
 &lt;td>是&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>寫 stateful UI 時、每個 state 的儲存位置是個設計選擇 — 不選 = 預設用 in-memory = 預設犧牲所有上面五個特性。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼-url-容易被忽略">為什麼 URL 容易被忽略&lt;/h2>
&lt;h3 id="url-是隱形維度">URL 是隱形維度&lt;/h3>
&lt;p>In-memory state 在 React useState / Vue ref / vanilla 變數裡 — 寫起來最便利、是「預設位置」。URL state 需要 &lt;code>URLSearchParams&lt;/code> + &lt;code>history.pushState&lt;/code> + &lt;code>popstate&lt;/code> listener、寫起來成本高。&lt;/p>
&lt;p>&lt;a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關&lt;/a> 直接解釋為什麼：URL state 是「對齊使用者期望」的位置（使用者預期 URL 包含 state、能分享）、in-memory 是「便利位置」。預設便利、要刻意才走對齊。&lt;/p>
&lt;h3 id="沒寫-url-state-的失敗訊號是-silent">沒寫 URL state 的失敗訊號是 silent&lt;/h3>
&lt;p>使用者打開搜尋頁、輸入「pagefind」、選擇 title-only filter、看到結果。這時：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>複製 URL 分享給朋友&lt;/strong> → 朋友打開看到空白搜尋框（query 不在 URL）&lt;/li>
&lt;li>&lt;strong>重整頁面&lt;/strong> → 自己也看到空白搜尋框&lt;/li>
&lt;li>&lt;strong>點 back&lt;/strong> → browser back 跳離搜尋頁、不是回到「沒 filter 的同個搜尋」&lt;/li>
&lt;/ul>
&lt;p>這三個動作沒有 error、沒有崩潰、就是「state 不見了」。使用者通常以為「網站就這樣」、不會 report bug。Silent 失敗 = 維護者永遠不知道有問題。&lt;/p>
&lt;p>對照 &lt;a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位&lt;/a> — 都是 silent 失敗、都是「該存在的東西不在」。&lt;/p>
&lt;hr>
&lt;h2 id="state-該寫進-url-的判準">State 該寫進 URL 的判準&lt;/h2>
&lt;h3 id="三問">三問&lt;/h3>
&lt;ol>
&lt;li>&lt;strong>使用者會分享這個 state 嗎&lt;/strong>？— 是 → URL（複製連結即帶 state）&lt;/li>
&lt;li>&lt;strong>使用者 reload 後預期 state 還在嗎&lt;/strong>？— 是 → URL 或 sessionStorage&lt;/li>
&lt;li>&lt;strong>使用者期望 browser back/forward 在 state 之間導航嗎&lt;/strong>？— 是 → URL&lt;/li>
&lt;/ol>
&lt;p>任一個「是」 → URL。&lt;/p>
&lt;h3 id="反向判準什麼不該寫進-url">反向判準：什麼不該寫進 URL&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>State 類型&lt;/th>
 &lt;th>為什麼不該寫進 URL&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Scroll position&lt;/td>
 &lt;td>頻繁變動破壞 history、且每個瀏覽器自己管&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Focus / hover state&lt;/td>
 &lt;td>Ephemeral、跟使用者操作直接綁定、寫進 URL 沒意義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Form 編輯中的暫存值&lt;/td>
 &lt;td>使用者沒提交、不該被分享&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>敏感資訊（token / 密碼）&lt;/td>
 &lt;td>URL 進 history / referer header / log、安全性問題&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>高頻 polling 結果&lt;/td>
 &lt;td>每秒變、history 爆炸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>內部 component state（折疊 / 展開動畫進度）&lt;/td>
 &lt;td>跟 UI 細節綁、不是使用者意圖&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="多面向常見-ui-元素的-url-state-對照">多面向：常見 UI 元素的 URL state 對照&lt;/h2>
&lt;h3 id="面向-1search-filter這次任務的-case">面向 1：Search filter（這次任務的 case）&lt;/h3>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">Query string、scope filter、type filter、tag filter
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">→ 都該進 URL：使用者會分享「我搜什麼 + 怎麼篩」&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>範例 URL：&lt;code>/search/?q=pagefind&amp;amp;scope=title&amp;amp;type=post&amp;amp;tag=js&lt;/code>&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<blockquote>
<p>State 的儲存層決定它的特性 — 可分享 / 可恢復 / 可導航 的 state 該寫進 URL、不寫進 = silent 把這些特性犧牲掉。</p></blockquote>
<table>
  <thead>
      <tr>
          <th>儲存層</th>
          <th>可分享</th>
          <th>可 reload 恢復</th>
          <th>可 back/forward 導航</th>
          <th>跨 tab 同步</th>
          <th>跨 device 同步</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>In-memory</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>URL</td>
          <td>是</td>
          <td>是</td>
          <td>是</td>
          <td>部分（同 URL）</td>
          <td>部分（複製連結）</td>
      </tr>
      <tr>
          <td>sessionStorage</td>
          <td>否</td>
          <td>是</td>
          <td>否</td>
          <td>否</td>
          <td>否</td>
      </tr>
      <tr>
          <td>localStorage</td>
          <td>否</td>
          <td>是</td>
          <td>否</td>
          <td>是（同 origin）</td>
          <td>否</td>
      </tr>
      <tr>
          <td>Server</td>
          <td>是</td>
          <td>是</td>
          <td>否</td>
          <td>是</td>
          <td>是</td>
      </tr>
  </tbody>
</table>
<p>寫 stateful UI 時、每個 state 的儲存位置是個設計選擇 — 不選 = 預設用 in-memory = 預設犧牲所有上面五個特性。</p>
<hr>
<h2 id="為什麼-url-容易被忽略">為什麼 URL 容易被忽略</h2>
<h3 id="url-是隱形維度">URL 是隱形維度</h3>
<p>In-memory state 在 React useState / Vue ref / vanilla 變數裡 — 寫起來最便利、是「預設位置」。URL state 需要 <code>URLSearchParams</code> + <code>history.pushState</code> + <code>popstate</code> listener、寫起來成本高。</p>
<p><a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a> 直接解釋為什麼：URL state 是「對齊使用者期望」的位置（使用者預期 URL 包含 state、能分享）、in-memory 是「便利位置」。預設便利、要刻意才走對齊。</p>
<h3 id="沒寫-url-state-的失敗訊號是-silent">沒寫 URL state 的失敗訊號是 silent</h3>
<p>使用者打開搜尋頁、輸入「pagefind」、選擇 title-only filter、看到結果。這時：</p>
<ul>
<li><strong>複製 URL 分享給朋友</strong> → 朋友打開看到空白搜尋框（query 不在 URL）</li>
<li><strong>重整頁面</strong> → 自己也看到空白搜尋框</li>
<li><strong>點 back</strong> → browser back 跳離搜尋頁、不是回到「沒 filter 的同個搜尋」</li>
</ul>
<p>這三個動作沒有 error、沒有崩潰、就是「state 不見了」。使用者通常以為「網站就這樣」、不會 report bug。Silent 失敗 = 維護者永遠不知道有問題。</p>
<p>對照 <a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位</a> — 都是 silent 失敗、都是「該存在的東西不在」。</p>
<hr>
<h2 id="state-該寫進-url-的判準">State 該寫進 URL 的判準</h2>
<h3 id="三問">三問</h3>
<ol>
<li><strong>使用者會分享這個 state 嗎</strong>？— 是 → URL（複製連結即帶 state）</li>
<li><strong>使用者 reload 後預期 state 還在嗎</strong>？— 是 → URL 或 sessionStorage</li>
<li><strong>使用者期望 browser back/forward 在 state 之間導航嗎</strong>？— 是 → URL</li>
</ol>
<p>任一個「是」 → URL。</p>
<h3 id="反向判準什麼不該寫進-url">反向判準：什麼不該寫進 URL</h3>
<table>
  <thead>
      <tr>
          <th>State 類型</th>
          <th>為什麼不該寫進 URL</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Scroll position</td>
          <td>頻繁變動破壞 history、且每個瀏覽器自己管</td>
      </tr>
      <tr>
          <td>Focus / hover state</td>
          <td>Ephemeral、跟使用者操作直接綁定、寫進 URL 沒意義</td>
      </tr>
      <tr>
          <td>Form 編輯中的暫存值</td>
          <td>使用者沒提交、不該被分享</td>
      </tr>
      <tr>
          <td>敏感資訊（token / 密碼）</td>
          <td>URL 進 history / referer header / log、安全性問題</td>
      </tr>
      <tr>
          <td>高頻 polling 結果</td>
          <td>每秒變、history 爆炸</td>
      </tr>
      <tr>
          <td>內部 component state（折疊 / 展開動畫進度）</td>
          <td>跟 UI 細節綁、不是使用者意圖</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="多面向常見-ui-元素的-url-state-對照">多面向：常見 UI 元素的 URL state 對照</h2>
<h3 id="面向-1search-filter這次任務的-case">面向 1：Search filter（這次任務的 case）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Query string、scope filter、type filter、tag filter
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ 都該進 URL：使用者會分享「我搜什麼 + 怎麼篩」</span></span></code></pre></div><p>範例 URL：<code>/search/?q=pagefind&amp;scope=title&amp;type=post&amp;tag=js</code></p>
<h3 id="面向-2tab--step-navigation">面向 2：Tab / step navigation</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Active tab、wizard step
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ 該進 URL：分享 = 直接打開該 tab/step</span></span></code></pre></div><p>範例：<code>/settings/?tab=notifications</code>、<code>/checkout/?step=payment</code></p>
<h3 id="面向-3sort--pagination">面向 3：Sort / pagination</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">排序欄位、頁碼
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ 該進 URL：分享 = 朋友看到同樣排序的同一頁</span></span></code></pre></div><p>範例：<code>/posts/?sort=date_desc&amp;page=3</code></p>
<h3 id="面向-4modal--drawer-開合">面向 4：Modal / drawer 開合</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">看情境：
</span></span><span class="line"><span class="ln">2</span><span class="cl">- 重要 modal（圖片預覽、編輯對話框）→ URL（可分享 / back 關閉）
</span></span><span class="line"><span class="ln">3</span><span class="cl">- 純 UX 提示 modal（welcome tour）→ in-memory（不該分享）</span></span></code></pre></div><h3 id="面向-5theme--ui-preference">面向 5：Theme / UI preference</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">Dark mode、字型大小
</span></span><span class="line"><span class="ln">2</span><span class="cl">→ localStorage（跨 session 但不分享、跟 device 綁）
</span></span><span class="line"><span class="ln">3</span><span class="cl">不進 URL（不會「分享你的 dark mode 設定」）</span></span></code></pre></div><hr>
<h2 id="url-state-的實作模式">URL state 的實作模式</h2>
<h3 id="讀載入時從-url-同步到-component-state">讀：載入時從 URL 同步到 component state</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">getInitialState</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">params</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">(</span><span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="nx">query</span><span class="o">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s1">&#39;q&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="s1">&#39;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">scope</span><span class="o">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s1">&#39;scope&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="s1">&#39;all&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">type</span><span class="o">:</span> <span class="nx">params</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s1">&#39;type&#39;</span><span class="p">)</span> <span class="o">||</span> <span class="kc">null</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="kr">const</span> <span class="nx">initialState</span> <span class="o">=</span> <span class="nx">getInitialState</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1">// component 用 initialState 初始化
</span></span></span></code></pre></div><h3 id="寫state-變動時同步到-url">寫：state 變動時同步到 URL</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">function</span> <span class="nx">syncUrl</span><span class="p">(</span><span class="nx">state</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">params</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URLSearchParams</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">query</span><span class="p">)</span> <span class="nx">params</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s1">&#39;q&#39;</span><span class="p">,</span> <span class="nx">state</span><span class="p">.</span><span class="nx">query</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">scope</span> <span class="o">&amp;&amp;</span> <span class="nx">state</span><span class="p">.</span><span class="nx">scope</span> <span class="o">!==</span> <span class="s1">&#39;all&#39;</span><span class="p">)</span> <span class="nx">params</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s1">&#39;scope&#39;</span><span class="p">,</span> <span class="nx">state</span><span class="p">.</span><span class="nx">scope</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">type</span><span class="p">)</span> <span class="nx">params</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s1">&#39;type&#39;</span><span class="p">,</span> <span class="nx">state</span><span class="p">.</span><span class="nx">type</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">location</span><span class="p">.</span><span class="nx">pathname</span><span class="si">}${</span><span class="nx">params</span><span class="p">.</span><span class="nx">toString</span><span class="p">()</span> <span class="o">?</span> <span class="s1">&#39;?&#39;</span> <span class="o">+</span> <span class="nx">params</span><span class="p">.</span><span class="nx">toString</span><span class="p">()</span> <span class="o">:</span> <span class="s1">&#39;&#39;</span><span class="si">}</span><span class="sb">`</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="nx">history</span><span class="p">.</span><span class="nx">replaceState</span><span class="p">(</span><span class="kc">null</span><span class="p">,</span> <span class="s1">&#39;&#39;</span><span class="p">,</span> <span class="nx">url</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 每次 state 變動觸發
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="nx">onStateChange</span><span class="p">((</span><span class="nx">newState</span><span class="p">)</span> <span class="p">=&gt;</span> <span class="nx">syncUrl</span><span class="p">(</span><span class="nx">newState</span><span class="p">));</span></span></span></code></pre></div><p>選擇 <code>replaceState</code> vs <code>pushState</code>：</p>
<ul>
<li><code>replaceState</code>：每次 state 變動覆蓋當前 history entry — back/forward 跳過中間狀態</li>
<li><code>pushState</code>：每次 state 變動加新 history entry — back 回到上一個 state</li>
</ul>
<p>通常 search filter / sort / pagination 用 <code>replaceState</code>（typing 太快、不該每個字符一個 history entry）；tab / step 用 <code>pushState</code>（每個 step 該 back 回上一個）。</p>
<h3 id="雙向聽-popstate-處理-backforward">雙向：聽 popstate 處理 back/forward</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nb">window</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;popstate&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">state</span> <span class="o">=</span> <span class="nx">getInitialState</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">applyStateToUI</span><span class="p">(</span><span class="nx">state</span><span class="p">);</span>  <span class="c1">// back/forward 後、把 state 套回 UI
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span><span class="p">});</span></span></span></code></pre></div><p>沒 listen popstate = back/forward 不會觸發 UI 更新、URL 跟 UI 不同步。</p>
<hr>
<h2 id="不該套用本原則的情境">不該套用本原則的情境</h2>
<p>「URL 是 state 儲存層」原則在「公開可分享的 UI」成立、但有合理例外：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼不該套用</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>內部 admin 工具</td>
          <td>不分享、不公開、URL persistence ROI 低</td>
      </tr>
      <tr>
          <td>Single-page wizard 強制流程</td>
          <td>不該允許 deep link 跳關卡（業務規則需要照順序走）</td>
      </tr>
      <tr>
          <td>一次性確認對話框</td>
          <td>不該被 back 回來、不該分享</td>
      </tr>
      <tr>
          <td>開發中的 prototype</td>
          <td>還沒穩定的 UI、不該固化 URL contract</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>跟本卡的關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../single-source-of-truth/">#44 SSOT</a></td>
          <td>URL 是 state 的 SSOT 候選 — 選對位置 = 一處可改、不選 = 多源 drift</td>
      </tr>
      <tr>
          <td><a href="../ease-of-writing-vs-intent-alignment/">#67 寫作便利度跟意圖對齊反相關</a></td>
          <td>In-memory state 是便利位置、URL state 是對齊（使用者預期）位置</td>
      </tr>
      <tr>
          <td><a href="../view-layer-filter-vs-source-layer/">#55 Filter × Source 層錯位</a></td>
          <td>都是 silent 失敗結構 — state 該在的位置不在、使用者沒訊號</td>
      </tr>
      <tr>
          <td><a href="../visual-completion-vs-functional-completion/">#56 視覺完成 ≠ 功能完成</a></td>
          <td>URL state 沒做 = 「畫面對了但 reload 後不見」是同類功能缺口</td>
      </tr>
      <tr>
          <td><a href="../pattern-explicit-semantic-narrowing/">#66 明示語意縮小</a></td>
          <td>「URL 不持久化」如果是設計選擇、要明示（「重整會清除狀態」hint）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="對應的實作篇">對應的實作篇</h2>
<ul>
<li>搜尋頁的 scope filter URL persistence — Phase 1+2 修完後 retrospective Checkpoint 1 才發現遺漏（#68 dogfooding）</li>
<li>任何 search / list / dashboard UI — 都該檢視 URL state coverage</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>寫互動 UI 但沒寫 URL 同步</td>
          <td>跑三問、確認該不該寫進 URL</td>
      </tr>
      <tr>
          <td>使用者 report「我分享連結給朋友、他看不到我看到的」</td>
          <td>URL state 缺漏的 silent 訊號顯現</td>
      </tr>
      <tr>
          <td><code>replaceState</code> 跟 <code>pushState</code> 沒區分、所有 state 變動用同一個</td>
          <td>評估：哪些是 history entry 該被記、哪些不該</td>
      </tr>
      <tr>
          <td>沒 listen <code>popstate</code></td>
          <td>back/forward 會 silent 失效、補 listener</td>
      </tr>
      <tr>
          <td>URL 變超長、含 ephemeral state</td>
          <td>過度寫進 URL、用反向判準砍掉不該寫的</td>
      </tr>
      <tr>
          <td>內心 OS：「state 用 useState 就好、URL 之後再說」</td>
          <td>「之後再說」= <a href="../ease-of-writing-vs-intent-alignment/">#67 reformer 謊言</a>、補不回來</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：URL 是 stateful UI 的隱形儲存層。沒寫 URL state = silent 犧牲分享 / 恢復 / 導航三個 UX 特性。寫之前跑三問（分享？reload？back/forward？）、任一個是 → URL。</p>
]]></content:encoded></item><item><title>L1 + L2 疊加時的訊號一致性：UX hint 跟自動 fallback 講的話要對齊</title><link>https://tarrragon.github.io/blog/report/layered-strategy-signal-consistency/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/layered-strategy-signal-consistency/</guid><description>&lt;h2 id="結論">結論&lt;/h2>
&lt;p>把 &lt;a href="../capability-gap-three-layer-escalation/">L1 expectation alignment + L2 augmenting computation 疊加&lt;/a> 時、兩個 layer 給使用者的訊號要&lt;strong>對齊、不是 redundant 也不是 conflicting&lt;/strong>：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>兩 layer 的關係&lt;/th>
 &lt;th>使用者體驗&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;strong>Conflicting&lt;/strong>（L1 說一回事、L2 做相反事）&lt;/td>
 &lt;td>困惑、不信任系統&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Redundant&lt;/strong>（L1 講 + L2 補的是同個東西）&lt;/td>
 &lt;td>噪音、L1 hint 失去意義&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Layered consistent&lt;/strong>（L1 講 capability、L2 自動補 + 訊號明示「這是 fallback」）&lt;/td>
 &lt;td>清楚、信任&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>設計三條原則：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>L2 自動補時、訊號要明示「這是 fallback、不是 primary path」&lt;/strong>&lt;/li>
&lt;li>&lt;strong>L1 hint 要承認 L2 的存在&lt;/strong>（不要假裝 L2 不存在）&lt;/li>
&lt;li>&lt;strong>使用者一直能 trace「這個結果怎麼來的」&lt;/strong>&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="為什麼疊加會打架">為什麼疊加會打架&lt;/h2>
&lt;p>L1 跟 L2 各自設計、不協調時、訊號會相互削弱：&lt;/p>
&lt;h3 id="conflicting-例search">Conflicting 例：search&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Layer&lt;/th>
 &lt;th>訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>L1 hint&lt;/td>
 &lt;td>&amp;ldquo;搜尋為前綴匹配、找 backpressure 請打 backpre&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>L2 fallback&lt;/td>
 &lt;td>自動 substring 找到 backpressure、顯示為 normal result&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>User 打 &amp;ldquo;pre&amp;rdquo; → 看到 backpressure 結果 → 困惑：「不是說要打 backpre？」 → 不確定下次該怎麼搜。&lt;/p>
&lt;h3 id="redundant-例retry-with-hint">Redundant 例：retry with hint&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Layer&lt;/th>
 &lt;th>訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>L1 hint&lt;/td>
 &lt;td>&amp;ldquo;網路不穩、稍後再試&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>L2 retry&lt;/td>
 &lt;td>已經自動 retry 3 次&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>User 看到 hint → 自己 manual retry → 但 system 已經在 retry → 操作冗餘 → 不確定 retry 是 user 觸發還是 system。&lt;/p>
&lt;h3 id="conflicting-例editor-stale-data">Conflicting 例：editor stale data&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Layer&lt;/th>
 &lt;th>訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>L1 banner&lt;/td>
 &lt;td>&amp;ldquo;資料同步可能延遲幾秒&amp;rdquo;&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>L2 fallback&lt;/td>
 &lt;td>Stale-while-revalidate 自動 refresh、user 沒感知&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>User 看到 banner、但每次資料其實都是 fresh（refresh 完成）→ banner 變 noise。Banner 撤掉後又會在某次 revalidation 失敗時 leak 出 stale data → 信任崩潰。&lt;/p>
&lt;hr>
&lt;h2 id="layered-consistency-的三設計原則">Layered Consistency 的三設計原則&lt;/h2>
&lt;h3 id="原則-1l2-自動補時訊號明示這是-fallback">原則 1：L2 自動補時、訊號明示「這是 fallback」&lt;/h3>
&lt;p>L2 不該無聲補強。當 L2 觸發、UI 應該標示：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Layered consistent 訊號&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Search prefix-only + substring fallback&lt;/td>
 &lt;td>Result 上方標 &amp;ldquo;找到 substring 匹配（非標準前綴）&amp;quot;、user 知道這是 fallback&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Retry on transient failure&lt;/td>
 &lt;td>Spinner + &amp;ldquo;重試中（第 N 次）&amp;quot;、user 不需自己 retry&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Stale-while-revalidate&lt;/td>
 &lt;td>&amp;ldquo;資料約 N 秒前&amp;rdquo;、user 知道是否需要 refresh&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>關鍵：&lt;strong>「自動補但隱形」是 silent UX&lt;/strong>、跟 &lt;a href="../literal-interception-vs-behavioral-refinement/">#82 字面攔截 vs 行為精煉&lt;/a> 的「false confidence」同骨。&lt;/p></description><content:encoded><![CDATA[<h2 id="結論">結論</h2>
<p>把 <a href="../capability-gap-three-layer-escalation/">L1 expectation alignment + L2 augmenting computation 疊加</a> 時、兩個 layer 給使用者的訊號要<strong>對齊、不是 redundant 也不是 conflicting</strong>：</p>
<table>
  <thead>
      <tr>
          <th>兩 layer 的關係</th>
          <th>使用者體驗</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Conflicting</strong>（L1 說一回事、L2 做相反事）</td>
          <td>困惑、不信任系統</td>
      </tr>
      <tr>
          <td><strong>Redundant</strong>（L1 講 + L2 補的是同個東西）</td>
          <td>噪音、L1 hint 失去意義</td>
      </tr>
      <tr>
          <td><strong>Layered consistent</strong>（L1 講 capability、L2 自動補 + 訊號明示「這是 fallback」）</td>
          <td>清楚、信任</td>
      </tr>
  </tbody>
</table>
<p>設計三條原則：</p>
<ol>
<li><strong>L2 自動補時、訊號要明示「這是 fallback、不是 primary path」</strong></li>
<li><strong>L1 hint 要承認 L2 的存在</strong>（不要假裝 L2 不存在）</li>
<li><strong>使用者一直能 trace「這個結果怎麼來的」</strong></li>
</ol>
<hr>
<h2 id="為什麼疊加會打架">為什麼疊加會打架</h2>
<p>L1 跟 L2 各自設計、不協調時、訊號會相互削弱：</p>
<h3 id="conflicting-例search">Conflicting 例：search</h3>
<table>
  <thead>
      <tr>
          <th>Layer</th>
          <th>訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1 hint</td>
          <td>&ldquo;搜尋為前綴匹配、找 backpressure 請打 backpre&rdquo;</td>
      </tr>
      <tr>
          <td>L2 fallback</td>
          <td>自動 substring 找到 backpressure、顯示為 normal result</td>
      </tr>
  </tbody>
</table>
<p>User 打 &ldquo;pre&rdquo; → 看到 backpressure 結果 → 困惑：「不是說要打 backpre？」 → 不確定下次該怎麼搜。</p>
<h3 id="redundant-例retry-with-hint">Redundant 例：retry with hint</h3>
<table>
  <thead>
      <tr>
          <th>Layer</th>
          <th>訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1 hint</td>
          <td>&ldquo;網路不穩、稍後再試&rdquo;</td>
      </tr>
      <tr>
          <td>L2 retry</td>
          <td>已經自動 retry 3 次</td>
      </tr>
  </tbody>
</table>
<p>User 看到 hint → 自己 manual retry → 但 system 已經在 retry → 操作冗餘 → 不確定 retry 是 user 觸發還是 system。</p>
<h3 id="conflicting-例editor-stale-data">Conflicting 例：editor stale data</h3>
<table>
  <thead>
      <tr>
          <th>Layer</th>
          <th>訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1 banner</td>
          <td>&ldquo;資料同步可能延遲幾秒&rdquo;</td>
      </tr>
      <tr>
          <td>L2 fallback</td>
          <td>Stale-while-revalidate 自動 refresh、user 沒感知</td>
      </tr>
  </tbody>
</table>
<p>User 看到 banner、但每次資料其實都是 fresh（refresh 完成）→ banner 變 noise。Banner 撤掉後又會在某次 revalidation 失敗時 leak 出 stale data → 信任崩潰。</p>
<hr>
<h2 id="layered-consistency-的三設計原則">Layered Consistency 的三設計原則</h2>
<h3 id="原則-1l2-自動補時訊號明示這是-fallback">原則 1：L2 自動補時、訊號明示「這是 fallback」</h3>
<p>L2 不該無聲補強。當 L2 觸發、UI 應該標示：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Layered consistent 訊號</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Search prefix-only + substring fallback</td>
          <td>Result 上方標 &ldquo;找到 substring 匹配（非標準前綴）&quot;、user 知道這是 fallback</td>
      </tr>
      <tr>
          <td>Retry on transient failure</td>
          <td>Spinner + &ldquo;重試中（第 N 次）&quot;、user 不需自己 retry</td>
      </tr>
      <tr>
          <td>Stale-while-revalidate</td>
          <td>&ldquo;資料約 N 秒前&rdquo;、user 知道是否需要 refresh</td>
      </tr>
  </tbody>
</table>
<p>關鍵：<strong>「自動補但隱形」是 silent UX</strong>、跟 <a href="../literal-interception-vs-behavioral-refinement/">#82 字面攔截 vs 行為精煉</a> 的「false confidence」同骨。</p>
<h3 id="原則-2l1-hint-要承認-l2-的存在">原則 2：L1 hint 要承認 L2 的存在</h3>
<p>L1 hint 不該假裝是「全部能做的事」：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">壞：搜尋為前綴匹配、找 backpressure 請打 backpre
</span></span><span class="line"><span class="ln">2</span><span class="cl">好：搜尋優先前綴匹配；找不到時會 fallback 到 substring（顯示時會標示）。
</span></span><span class="line"><span class="ln">3</span><span class="cl">   想精準找 backpressure 直接打完整詞、或打 backpre。</span></span></code></pre></div><p>L1 講 capability + L2 講 fallback、合在一起 = 完整的 mental model。</p>
<h3 id="原則-3可-trace-結果怎麼來的">原則 3：可 trace 「結果怎麼來的」</h3>
<p>User 能（不必、但能）看到結果的來源層：</p>
<ul>
<li>Search result 標 &ldquo;prefix match&rdquo; / &ldquo;substring fallback&rdquo;</li>
<li>API response 標 <code>from_cache: true</code> 或 <code>freshness_seconds: 30</code></li>
<li>LLM response 標「來自 RAG retrieval / 來自 base model knowledge」</li>
</ul>
<p>可 trace ≠ 強制顯示、是「想知道時可以知道」。預設可隱藏、debug / 進階 user 可展開。</p>
<hr>
<h2 id="反模式">反模式</h2>
<table>
  <thead>
      <tr>
          <th>反模式</th>
          <th>後果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L2 隱形補強、L1 hint 沒提 L2</td>
          <td>使用者不知道有 fallback、抱怨 hint 不準</td>
      </tr>
      <tr>
          <td>L1 hint + L2 自動 retry 都顯示</td>
          <td>Redundant、user 重複動作</td>
      </tr>
      <tr>
          <td>L2 失敗時退回 L1 但訊號沒切換</td>
          <td>User 看到舊 hint、實際 system 在另一狀態</td>
      </tr>
      <tr>
          <td>「不要讓 user 看到 fallback」當原則</td>
          <td>Silent fallback 是 <a href="../visual-completion-vs-functional-completion/">#56 視覺完成 vs 功能完成</a> 的反例</td>
      </tr>
      <tr>
          <td>L1 / L2 是不同 team 設計、沒協調</td>
          <td>訊號自然衝突、需要 cross-team review</td>
      </tr>
      <tr>
          <td>Telemetry 沒分 L1 / L2 觸發比例</td>
          <td>不知道哪 layer 真的解 gap</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="何時-conflicting--redundant-是合理的">何時 conflicting / redundant 是合理的</h2>
<p>少數情境：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>為什麼 conflicting / redundant 可接受</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1 是 legal disclaimer（必要法律文字）</td>
          <td>法律要求、不能因 L2 拿掉</td>
      </tr>
      <tr>
          <td>L2 是 emergency fallback、L1 是 primary</td>
          <td>各自負責不同 case、訊號可重疊</td>
      </tr>
      <tr>
          <td>安全 critical 多重提醒</td>
          <td>重要訊號值得 redundant</td>
      </tr>
  </tbody>
</table>
<p>三類共通：<strong>訊號重複的成本 &lt; 訊號漏掉的成本</strong>。其他情境追求 layered consistent。</p>
<hr>
<h2 id="跟其他抽象層原則的關係">跟其他抽象層原則的關係</h2>
<table>
  <thead>
      <tr>
          <th>原則</th>
          <th>關係</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="../main-strategy-plus-supplementary/">#75 主策略 + 補強疊加</a></td>
          <td>#75 講疊加可行、本卡講疊加後 UX 訊號層怎麼設計</td>
      </tr>
      <tr>
          <td><a href="../capability-gap-three-layer-escalation/">#86 Capability gap 三層階梯</a></td>
          <td>#86 講選哪層、本卡講疊加多層時訊號</td>
      </tr>
      <tr>
          <td><a href="../decision-dialogue-dimensions/">#79 決策對話的五維度</a></td>
          <td>「使用者看到什麼」是 decision dialogue 的「呈現」維度、本卡是其特化</td>
      </tr>
      <tr>
          <td><a href="../visual-completion-vs-functional-completion/">#56 視覺完成 vs 功能完成</a></td>
          <td>Silent L2 fallback 是「視覺完成、功能不誠實」的變種</td>
      </tr>
      <tr>
          <td><a href="../pattern-honest-progress-ui/">#62 誠實進度 UI</a></td>
          <td>本卡的「fallback 訊號明示」原則跟誠實進度同骨</td>
      </tr>
      <tr>
          <td><a href="../literal-interception-vs-behavioral-refinement/">#82 字面攔截 vs 行為精煉</a></td>
          <td>「自動補但隱形」是 false confidence 的 UX 變種</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="套用到當前-search-planning-case">套用到當前 search planning case</h2>
<p>D + C1 疊加 case：</p>
<p><strong>Bad</strong>（conflicting）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">D hint: &#34;搜尋為前綴匹配、找 backpressure 請打 backpre&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">C1 fallback: 打 &#34;pre&#34; 自動 substring 找到 backpressure、跟其他 prefix result 混排</span></span></code></pre></div><p><strong>Good</strong>（layered consistent）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">D hint: &#34;搜尋優先前綴匹配。找不到時自動 fallback 到 substring（會標示）。&#34;
</span></span><span class="line"><span class="ln">2</span><span class="cl">C1 fallback UI:
</span></span><span class="line"><span class="ln">3</span><span class="cl">  - Prefix matches（標準）：[後跟前綴匹配 results]
</span></span><span class="line"><span class="ln">4</span><span class="cl">  - Substring matches（fallback）：[標示後跟 fallback results]</span></span></code></pre></div><p>User 看到的：</p>
<ul>
<li>打 &ldquo;pre&rdquo; → 立刻看到 prefix matches（如「prefetch」）</li>
<li>同頁標 &ldquo;Substring fallback&rdquo; 段、列「backpressure」等 substring 命中</li>
<li>看 hint 也知道為什麼有兩段</li>
</ul>
<p>訊號對齊、user mental model 完整。</p>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>L1 hint 寫完才寫 L2、沒重 review L1</td>
          <td>退回重看 L1 是否承認 L2</td>
      </tr>
      <tr>
          <td>L2 自動補但 UI 看不出來</td>
          <td>加 fallback 訊號</td>
      </tr>
      <tr>
          <td>User 抱怨「hint 跟實際不一致」</td>
          <td>Layered consistency 沒做、補上</td>
      </tr>
      <tr>
          <td>L1 / L2 telemetry 沒分</td>
          <td>不知道誰實際 close gap、補</td>
      </tr>
      <tr>
          <td>Hint 越寫越長</td>
          <td>可能 L2 沒 surface、L1 在補 L2 該講的</td>
      </tr>
      <tr>
          <td>「user 看不到 fallback 比較單純」直覺</td>
          <td>Silent UX 反模式、 fallback 該明示</td>
      </tr>
  </tbody>
</table>
<p><strong>核心</strong>：L1 + L2 疊加不是「兩個獨立 layer 各自做事」、是<strong>一個 capability gap 上的兩個訊號</strong>。訊號要對齊、否則使用者收到的 mental model 是 broken。<strong>Silent fallback 看起來簡潔、實際是 false confidence</strong>。</p>
]]></content:encoded></item><item><title>每個畫面都需要出口：畫面狀態機設計與 UX 導航的系統性方法</title><link>https://tarrragon.github.io/blog/work-log/%E6%AF%8F%E5%80%8B%E7%95%AB%E9%9D%A2%E9%83%BD%E9%9C%80%E8%A6%81%E5%87%BA%E5%8F%A3%E7%95%AB%E9%9D%A2%E7%8B%80%E6%85%8B%E6%A9%9F%E8%A8%AD%E8%A8%88%E8%88%87-ux-%E5%B0%8E%E8%88%AA%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%96%B9%E6%B3%95/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E6%AF%8F%E5%80%8B%E7%95%AB%E9%9D%A2%E9%83%BD%E9%9C%80%E8%A6%81%E5%87%BA%E5%8F%A3%E7%95%AB%E9%9D%A2%E7%8B%80%E6%85%8B%E6%A9%9F%E8%A8%AD%E8%A8%88%E8%88%87-ux-%E5%B0%8E%E8%88%AA%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%96%B9%E6%B3%95/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>使用者連上遠端終端機後、無法返回首頁。&lt;/p>&lt;/blockquote>
&lt;p>這是設計遺漏。Terminal 畫面的 &lt;code>connected&lt;/code> 狀態沒有 disconnect 按鈕也沒有 back 按鈕。&lt;code>error&lt;/code> 和 &lt;code>disconnected&lt;/code> 狀態也沒有。使用者被困在畫面裡，唯一的出路是殺掉 app。&lt;/p>
&lt;p>這不是「忘記加按鈕」的問題。回頭看企劃文件，操作盤點段確實列了「連線失敗顯示無法連線」這個失敗情境，但沒有系統性地問：&lt;strong>這個畫面有幾個狀態？每個狀態能做什麼操作？怎麼離開？&lt;/strong>&lt;/p>
&lt;p>本文整理畫面狀態機設計的方法、示範用狀態矩陣捕捉導航缺口、歸納 mobile app UX 的三個設計原則。&lt;/p>
&lt;hr>
&lt;h2 id="實際案例terminal-畫面的五個狀態">實際案例：Terminal 畫面的五個狀態&lt;/h2>
&lt;p>Terminal 畫面有一個 &lt;code>TerminalScreenUiState&lt;/code> enum 定義了五個狀態：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="n">enum&lt;/span> &lt;span class="n">TerminalScreenUiState&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="n">idle&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">connecting&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">connected&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">error&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">disconnected&lt;/span> &lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>實機測試前、這五個狀態各自的 UI 長這樣：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>狀態&lt;/th>
 &lt;th>顯示&lt;/th>
 &lt;th>可用操作&lt;/th>
 &lt;th>退出路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>idle&lt;/td>
 &lt;td>空白（自動開始連線）&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connecting&lt;/td>
 &lt;td>「連線中&amp;hellip;」進度指示&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connected&lt;/td>
 &lt;td>終端機畫面 + 工具列&lt;/td>
 &lt;td>打字、Esc/Tab/Ctrl/方向鍵&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>錯誤訊息 + 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>disconnected&lt;/td>
 &lt;td>「連線中斷」+ 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>&lt;strong>無&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>五個狀態、零個退出路徑。使用者一旦進入 Terminal 畫面就出不去。&lt;/p>
&lt;hr>
&lt;h2 id="問題不在按鈕在設計方法">問題不在按鈕、在設計方法&lt;/h2>
&lt;p>加 back 按鈕是 5 分鐘的事。真正的問題是：&lt;strong>企劃階段沒有工具強制你為每個狀態想退出路徑。&lt;/strong>&lt;/p>
&lt;p>操作盤點表長這樣：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>操作&lt;/th>
 &lt;th>主情境&lt;/th>
 &lt;th>失敗情境&lt;/th>
 &lt;th>前端引導&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>日常連線&lt;/td>
 &lt;td>Face ID → 讀憑證 → WS 連線 → 雙向 I/O&lt;/td>
 &lt;td>辨識失敗；Tailscale 離線；ttyd 認證失敗&lt;/td>
 &lt;td>辨識失敗不讀憑證；連線失敗顯示「無法連線」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「前端引導」只有一句話。它沒有被展開成畫面狀態。「連線失敗顯示無法連線」這句話覆蓋了 &lt;code>error&lt;/code> 狀態的&lt;strong>顯示&lt;/strong>，但沒有回答&lt;strong>操作&lt;/strong>（重連？返回？）和&lt;strong>退出&lt;/strong>（怎麼離開這個畫面？）。&lt;/p>
&lt;hr>
&lt;h2 id="畫面狀態矩陣">畫面狀態矩陣&lt;/h2>
&lt;p>把狀態機設計變成一張表，強制回答每個狀態的四個面向：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>畫面.狀態&lt;/th>
 &lt;th>顯示&lt;/th>
 &lt;th>可用操作&lt;/th>
 &lt;th>進入條件&lt;/th>
 &lt;th>退出路徑&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Terminal.idle&lt;/td>
 &lt;td>空白&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>從首頁導航進入&lt;/td>
 &lt;td>back → 首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.connecting&lt;/td>
 &lt;td>進度指示&lt;/td>
 &lt;td>—&lt;/td>
 &lt;td>自動觸發連線&lt;/td>
 &lt;td>back → 首頁（取消連線）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.connected&lt;/td>
 &lt;td>終端機 + 工具列&lt;/td>
 &lt;td>打字、特殊鍵&lt;/td>
 &lt;td>WS 連線成功&lt;/td>
 &lt;td>disconnect → idle；back → 首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.error&lt;/td>
 &lt;td>錯誤訊息&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>連線失敗&lt;/td>
 &lt;td>back → 首頁；retry → connecting&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Terminal.disconnected&lt;/td>
 &lt;td>「連線中斷」&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>WS 斷線&lt;/td>
 &lt;td>back → 首頁；retry → connecting&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>表格的威力在「退出路徑」欄位：&lt;strong>如果這格是空的，這就是一個 UX 死胡同。&lt;/strong>&lt;/p>
&lt;hr>
&lt;h2 id="三個-mobile-app-ux-設計原則">三個 Mobile App UX 設計原則&lt;/h2>
&lt;p>從這個案例提煉出的三個原則，適用於所有 mobile app：&lt;/p>
&lt;h3 id="原則-1每個畫面的每個狀態都需要退出路徑">原則 1：每個畫面的每個狀態都需要退出路徑&lt;/h3>
&lt;p>沒有例外。即使是「connecting」這種過渡狀態，使用者也可能想取消。iOS 的 HIG 和 Material Design 都要求 modal 畫面提供 dismiss 機制 — 如果使用者進不了某個狀態的下一步（連線失敗、timeout、服務無回應），他至少得能退出。&lt;/p>
&lt;p>&lt;strong>反模式&lt;/strong>：假設使用者只走 happy path。「connected 之後使用者不會想回首頁」是開發者的假設，不是使用者的需求。&lt;/p>
&lt;h3 id="原則-2gate-必須有-fallback">原則 2：Gate 必須有 fallback&lt;/h3>
&lt;p>Gate = 使用者必須通過的關卡（biometric、network、auth）。每個 gate 的設計不只是「成功時怎麼做」，還包含「失敗時的替代路徑」。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Gate&lt;/th>
 &lt;th>成功&lt;/th>
 &lt;th>失敗 fallback&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Biometric（Face ID / 指紋）&lt;/td>
 &lt;td>讀取憑證、繼續連線&lt;/td>
 &lt;td>密碼 fallback（&lt;code>biometricOnly: false&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Network（Tailscale VPN）&lt;/td>
 &lt;td>WS 連線&lt;/td>
 &lt;td>顯示「網路不可用」+ 重試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Auth（ttyd basic auth）&lt;/td>
 &lt;td>進入終端機&lt;/td>
 &lt;td>顯示「認證失敗」+ 建議重新配對&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;code>biometricOnly: true&lt;/code> 就是缺少 fallback 的典型案例 — Face ID 不可用（戴口罩、光線差、指紋模糊）時使用者直接被擋住，沒有替代方案。改為 &lt;code>biometricOnly: false&lt;/code> 讓系統提供密碼 fallback。&lt;/p></description><content:encoded><![CDATA[<h2 id="這篇要解決什麼">這篇要解決什麼</h2>
<blockquote>
<p>使用者連上遠端終端機後、無法返回首頁。</p></blockquote>
<p>這是設計遺漏。Terminal 畫面的 <code>connected</code> 狀態沒有 disconnect 按鈕也沒有 back 按鈕。<code>error</code> 和 <code>disconnected</code> 狀態也沒有。使用者被困在畫面裡，唯一的出路是殺掉 app。</p>
<p>這不是「忘記加按鈕」的問題。回頭看企劃文件，操作盤點段確實列了「連線失敗顯示無法連線」這個失敗情境，但沒有系統性地問：<strong>這個畫面有幾個狀態？每個狀態能做什麼操作？怎麼離開？</strong></p>
<p>本文整理畫面狀態機設計的方法、示範用狀態矩陣捕捉導航缺口、歸納 mobile app UX 的三個設計原則。</p>
<hr>
<h2 id="實際案例terminal-畫面的五個狀態">實際案例：Terminal 畫面的五個狀態</h2>
<p>Terminal 畫面有一個 <code>TerminalScreenUiState</code> enum 定義了五個狀態：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="n">enum</span> <span class="n">TerminalScreenUiState</span> <span class="p">{</span> <span class="n">idle</span><span class="p">,</span> <span class="n">connecting</span><span class="p">,</span> <span class="n">connected</span><span class="p">,</span> <span class="n">error</span><span class="p">,</span> <span class="n">disconnected</span> <span class="p">}</span></span></span></code></pre></div><p>實機測試前、這五個狀態各自的 UI 長這樣：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>顯示</th>
          <th>可用操作</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idle</td>
          <td>空白（自動開始連線）</td>
          <td>無</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>connecting</td>
          <td>「連線中&hellip;」進度指示</td>
          <td>無</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>connected</td>
          <td>終端機畫面 + 工具列</td>
          <td>打字、Esc/Tab/Ctrl/方向鍵</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>error</td>
          <td>錯誤訊息 + 重連按鈕</td>
          <td>重新連線</td>
          <td><strong>無</strong></td>
      </tr>
      <tr>
          <td>disconnected</td>
          <td>「連線中斷」+ 重連按鈕</td>
          <td>重新連線</td>
          <td><strong>無</strong></td>
      </tr>
  </tbody>
</table>
<p>五個狀態、零個退出路徑。使用者一旦進入 Terminal 畫面就出不去。</p>
<hr>
<h2 id="問題不在按鈕在設計方法">問題不在按鈕、在設計方法</h2>
<p>加 back 按鈕是 5 分鐘的事。真正的問題是：<strong>企劃階段沒有工具強制你為每個狀態想退出路徑。</strong></p>
<p>操作盤點表長這樣：</p>
<table>
  <thead>
      <tr>
          <th>操作</th>
          <th>主情境</th>
          <th>失敗情境</th>
          <th>前端引導</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>日常連線</td>
          <td>Face ID → 讀憑證 → WS 連線 → 雙向 I/O</td>
          <td>辨識失敗；Tailscale 離線；ttyd 認證失敗</td>
          <td>辨識失敗不讀憑證；連線失敗顯示「無法連線」</td>
      </tr>
  </tbody>
</table>
<p>「前端引導」只有一句話。它沒有被展開成畫面狀態。「連線失敗顯示無法連線」這句話覆蓋了 <code>error</code> 狀態的<strong>顯示</strong>，但沒有回答<strong>操作</strong>（重連？返回？）和<strong>退出</strong>（怎麼離開這個畫面？）。</p>
<hr>
<h2 id="畫面狀態矩陣">畫面狀態矩陣</h2>
<p>把狀態機設計變成一張表，強制回答每個狀態的四個面向：</p>
<table>
  <thead>
      <tr>
          <th>畫面.狀態</th>
          <th>顯示</th>
          <th>可用操作</th>
          <th>進入條件</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Terminal.idle</td>
          <td>空白</td>
          <td>—</td>
          <td>從首頁導航進入</td>
          <td>back → 首頁</td>
      </tr>
      <tr>
          <td>Terminal.connecting</td>
          <td>進度指示</td>
          <td>—</td>
          <td>自動觸發連線</td>
          <td>back → 首頁（取消連線）</td>
      </tr>
      <tr>
          <td>Terminal.connected</td>
          <td>終端機 + 工具列</td>
          <td>打字、特殊鍵</td>
          <td>WS 連線成功</td>
          <td>disconnect → idle；back → 首頁</td>
      </tr>
      <tr>
          <td>Terminal.error</td>
          <td>錯誤訊息</td>
          <td>重新連線</td>
          <td>連線失敗</td>
          <td>back → 首頁；retry → connecting</td>
      </tr>
      <tr>
          <td>Terminal.disconnected</td>
          <td>「連線中斷」</td>
          <td>重新連線</td>
          <td>WS 斷線</td>
          <td>back → 首頁；retry → connecting</td>
      </tr>
  </tbody>
</table>
<p>表格的威力在「退出路徑」欄位：<strong>如果這格是空的，這就是一個 UX 死胡同。</strong></p>
<hr>
<h2 id="三個-mobile-app-ux-設計原則">三個 Mobile App UX 設計原則</h2>
<p>從這個案例提煉出的三個原則，適用於所有 mobile app：</p>
<h3 id="原則-1每個畫面的每個狀態都需要退出路徑">原則 1：每個畫面的每個狀態都需要退出路徑</h3>
<p>沒有例外。即使是「connecting」這種過渡狀態，使用者也可能想取消。iOS 的 HIG 和 Material Design 都要求 modal 畫面提供 dismiss 機制 — 如果使用者進不了某個狀態的下一步（連線失敗、timeout、服務無回應），他至少得能退出。</p>
<p><strong>反模式</strong>：假設使用者只走 happy path。「connected 之後使用者不會想回首頁」是開發者的假設，不是使用者的需求。</p>
<h3 id="原則-2gate-必須有-fallback">原則 2：Gate 必須有 fallback</h3>
<p>Gate = 使用者必須通過的關卡（biometric、network、auth）。每個 gate 的設計不只是「成功時怎麼做」，還包含「失敗時的替代路徑」。</p>
<table>
  <thead>
      <tr>
          <th>Gate</th>
          <th>成功</th>
          <th>失敗 fallback</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Biometric（Face ID / 指紋）</td>
          <td>讀取憑證、繼續連線</td>
          <td>密碼 fallback（<code>biometricOnly: false</code>）</td>
      </tr>
      <tr>
          <td>Network（Tailscale VPN）</td>
          <td>WS 連線</td>
          <td>顯示「網路不可用」+ 重試</td>
      </tr>
      <tr>
          <td>Auth（ttyd basic auth）</td>
          <td>進入終端機</td>
          <td>顯示「認證失敗」+ 建議重新配對</td>
      </tr>
  </tbody>
</table>
<p><code>biometricOnly: true</code> 就是缺少 fallback 的典型案例 — Face ID 不可用（戴口罩、光線差、指紋模糊）時使用者直接被擋住，沒有替代方案。改為 <code>biometricOnly: false</code> 讓系統提供密碼 fallback。</p>
<h3 id="原則-3輸入機制是設計產物不是實作細節">原則 3：輸入機制是設計產物，不是實作細節</h3>
<p>「手機打字操作 CLI」的輸入設計決策比想像的多：</p>
<table>
  <thead>
      <tr>
          <th>設計決策</th>
          <th>選項</th>
          <th>取捨</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Keyboard type</td>
          <td><code>visiblePassword</code>（無自動校正）vs <code>text</code>（有校正）</td>
          <td>CLI 命令不需要自動校正，<code>visiblePassword</code> 避免系統「幫忙」修改輸入</td>
      </tr>
      <tr>
          <td>Submit model</td>
          <td>Enter 送出整行 vs 逐字元即時送出</td>
          <td>整行送出減少網路來回，但沒有即時 tab 補全回饋</td>
      </tr>
      <tr>
          <td>IME policy</td>
          <td>關閉建議、關閉自動校正、關閉個人化學習</td>
          <td>CLI 輸入內容可能包含密碼和路徑，IME 學習是安全風險</td>
      </tr>
      <tr>
          <td>Special keys</td>
          <td>Esc / Tab / Ctrl 組合鍵</td>
          <td>手機鍵盤沒有這些鍵，需要自訂工具列</td>
      </tr>
  </tbody>
</table>
<p>這些決策在企劃階段就應該做，因為它們影響 UI layout（是否需要輸入框？工具列放什麼鍵？）和 protocol 設計（逐字元還是整行？）。事後補的 <code>TextField</code> 參數列表（<code>enableSuggestions: false, autocorrect: false, enableIMEPersonalizedLearning: false</code>）全是散落的 hotfix，不是設計產物。</p>
<hr>
<h2 id="系統性方法從操作盤點到畫面狀態矩陣">系統性方法：從操作盤點到畫面狀態矩陣</h2>
<p>操作盤點是 BDD 的起點（使用者做什麼、成功時發生什麼、失敗時發生什麼）。但盤點到「前端引導」就停了 — 它回答了「顯示什麼」但沒回答「能做什麼」「怎麼離開」。</p>
<p>補上的步驟：</p>
<ol>
<li><strong>從操作盤點列出所有畫面</strong>：每個操作涉及哪些畫面？（首頁 → 配對畫面 → QR 掃描 → 終端機畫面）</li>
<li><strong>每個畫面列出所有狀態</strong>：這個畫面有哪些 enum 值或邏輯分支？</li>
<li><strong>填畫面狀態矩陣</strong>：顯示 / 可用操作 / 進入條件 / 退出路徑。退出路徑欄位為空 = UX 死胡同</li>
<li><strong>每個 gate 標注 fallback</strong>：biometric / network / auth 各有什麼替代方案？</li>
<li><strong>輸入機制列決策表</strong>：keyboard type / submit model / IME policy / special keys</li>
</ol>
<p>這是操作盤點本來就該產出的下一層。一張表能在 10 分鐘內暴露所有 UX 死胡同，省掉實機測試才發現的成本。</p>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>本文的觀察和判讀在 <a href="/blog/ux-design/" data-link-title="UX 設計實務指南" data-link-desc="整理畫面狀態機、導航設計、Gate fallback、輸入機制與使用者行為驗證 — 從「使用者被困在畫面裡出不去」的結構性遺漏出發，建立系統性的 UX 設計方法">UX Design 畫面設計</a> 教學系列中展開為系統性的教學模組：<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</a>、<a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a>、<a href="/blog/ux-design/03-input-mechanism/four-dimension-decision/" data-link-title="輸入機制決策表" data-link-desc="Keyboard type / submit model / IME policy / special keys 四個維度的決策框架 — 每個維度都是設計決策，影響 UI layout 和 protocol">輸入機制決策表</a>。</p>
]]></content:encoded></item></channel></rss>