<?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-Design on Tarragon</title><link>https://tarrragon.github.io/blog/tags/ux-design/</link><description>Recent content in Ux-Design 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-design/index.xml" rel="self" type="application/rss+xml"/><item><title>Gate 分類與三問設計法</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate&lt;/a> 是使用者操作流程中的「必須通過才能繼續」的關卡。生物辨識認證、網路連線檢查、權限請求、版本檢查 — 這些都是 gate。Gate 設計的核心責任是確保使用者在每種結果下都有路可走，而非只設計「通過」的情境。&lt;/p>
&lt;h2 id="三問設計法">三問設計法&lt;/h2>
&lt;p>每個 gate 設計時回答三個問題：&lt;/p>
&lt;h3 id="成功時做什麼">成功時做什麼&lt;/h3>
&lt;p>Gate 通過後使用者進入下一步。這是最直覺的設計 — 認證成功進入主畫面、網路連線成功開始載入資料、權限授予後啟用功能。&lt;/p>
&lt;p>成功路徑通常是設計時最先考慮的，也是最不容易遺漏的。&lt;/p>
&lt;h3 id="失敗時做什麼">失敗時做什麼&lt;/h3>
&lt;p>Gate 未通過時使用者的&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">替代路徑&lt;/a>。替代路徑可以是：降級功能（部分功能可用）、替代驗證方式（密碼代替 Face ID）、手動重試（重試按鈕）、放棄操作（返回上一頁）。&lt;/p>
&lt;p>失敗路徑是最容易遺漏的。app_tunnel 的 biometric gate 設定 &lt;code>biometricOnly: true&lt;/code>，Face ID 不可用時使用者直接被擋住，沒有密碼 fallback、沒有跳過選項、沒有返回路徑（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。修復只改一個 boolean — &lt;code>biometricOnly: false&lt;/code> — 讓系統自動提示輸入裝置密碼。但這個決策應該在企劃階段做，而非實機測試時才發現。&lt;/p>
&lt;h3 id="使用者不知道發生什麼時做什麼">使用者不知道發生什麼時做什麼&lt;/h3>
&lt;p>Gate 處理中（loading）或結果不確定（timeout）時使用者看到什麼、能做什麼。&lt;/p>
&lt;p>使用者不知道發生什麼的情境包括：認證彈窗尚未出現（系統延遲）、網路請求已發但未回應（loading）、權限對話框被系統遮擋（多個 dialog 堆疊）。&lt;/p>
&lt;p>在這個狀態下使用者需要的是：知道系統在做什麼（loading 指示）、可以取消等待（取消按鈕）、超過合理時間後有提示（timeout 訊息 + 重試選項）。&lt;/p>
&lt;h2 id="gate-的四種常見類型">Gate 的四種常見類型&lt;/h2>
&lt;h3 id="認證-gate">認證 Gate&lt;/h3>
&lt;p>使用者必須驗證身份才能使用功能。生物辨識、密碼、PIN 碼、OAuth 登入。&lt;/p>
&lt;p>認證 gate 的 fallback 設計取決於安全需求和使用場景。銀行 app 可能要求生物辨識 + PIN 碼雙重驗證，沒有更低層級的 fallback。自用工具可以接受密碼 fallback，因為使用者本身就是 owner — 可用性優先於認證強度（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h3 id="網路-gate">網路 Gate&lt;/h3>
&lt;p>功能需要網路連線才能運作。連線存在但不穩定的場景比完全離線更難處理 — 請求可能成功、可能逾時、可能部分成功。&lt;/p>
&lt;h3 id="權限-gate">權限 Gate&lt;/h3>
&lt;p>App 需要系統權限（相機、位置、通知）才能使用特定功能。&lt;/p>
&lt;p>權限 gate 的特殊性在於使用者可以永久拒絕。拒絕後再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。&lt;/p>
&lt;h3 id="環境-gate">環境 Gate&lt;/h3>
&lt;p>特定的硬體或軟體條件必須滿足。最低 OS 版本、特定感測器（NFC、深度相機）、特定連接（藍牙已開啟）。&lt;/p>
&lt;p>環境 gate 的 fallback 通常有限 — 硬體不存在時無法用軟體模擬。但至少應該告知使用者為什麼功能不可用，而非靜默禁用。&lt;/p>
&lt;h3 id="其他常見-gate">其他常見 Gate&lt;/h3>
&lt;p>商業 app 還有兩種 gate 在本系列涵蓋範圍之外但實務常見：&lt;/p>
&lt;p>&lt;strong>付費 Gate&lt;/strong>（paywall）：功能需要付費才能使用。付費 gate 的 fallback 設計和上述四種不同 — 「失敗」路徑的目標是引導使用者付費而非提供替代功能。試用期、降級功能、付費引導 vs 付費強制的取捨依賴商業模式決策。&lt;/p>
&lt;p>&lt;strong>版本相容性 Gate&lt;/strong>：API 版本過舊需要升級 app。Fallback 是提示使用者更新，但強制更新會阻擋無法更新的使用者（舊 OS 版本不支援新版 app）。&lt;/p>
&lt;h2 id="gate-設計表">Gate 設計表&lt;/h2>
&lt;p>把三問設計法應用到每個 gate，產出一張設計表：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Gate&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>進入主畫面&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;td>顯示 loading + 取消&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>相機權限&lt;/td>
 &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;td>顯示搜尋中 + 取消&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>失敗欄和不確定欄為空的 gate 就是 UX 死胡同的候選 — 和&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣&lt;/a>的退出路徑檢查同樣的邏輯。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate</a> 是使用者操作流程中的「必須通過才能繼續」的關卡。生物辨識認證、網路連線檢查、權限請求、版本檢查 — 這些都是 gate。Gate 設計的核心責任是確保使用者在每種結果下都有路可走，而非只設計「通過」的情境。</p>
<h2 id="三問設計法">三問設計法</h2>
<p>每個 gate 設計時回答三個問題：</p>
<h3 id="成功時做什麼">成功時做什麼</h3>
<p>Gate 通過後使用者進入下一步。這是最直覺的設計 — 認證成功進入主畫面、網路連線成功開始載入資料、權限授予後啟用功能。</p>
<p>成功路徑通常是設計時最先考慮的，也是最不容易遺漏的。</p>
<h3 id="失敗時做什麼">失敗時做什麼</h3>
<p>Gate 未通過時使用者的<a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">替代路徑</a>。替代路徑可以是：降級功能（部分功能可用）、替代驗證方式（密碼代替 Face ID）、手動重試（重試按鈕）、放棄操作（返回上一頁）。</p>
<p>失敗路徑是最容易遺漏的。app_tunnel 的 biometric gate 設定 <code>biometricOnly: true</code>，Face ID 不可用時使用者直接被擋住，沒有密碼 fallback、沒有跳過選項、沒有返回路徑（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。修復只改一個 boolean — <code>biometricOnly: false</code> — 讓系統自動提示輸入裝置密碼。但這個決策應該在企劃階段做，而非實機測試時才發現。</p>
<h3 id="使用者不知道發生什麼時做什麼">使用者不知道發生什麼時做什麼</h3>
<p>Gate 處理中（loading）或結果不確定（timeout）時使用者看到什麼、能做什麼。</p>
<p>使用者不知道發生什麼的情境包括：認證彈窗尚未出現（系統延遲）、網路請求已發但未回應（loading）、權限對話框被系統遮擋（多個 dialog 堆疊）。</p>
<p>在這個狀態下使用者需要的是：知道系統在做什麼（loading 指示）、可以取消等待（取消按鈕）、超過合理時間後有提示（timeout 訊息 + 重試選項）。</p>
<h2 id="gate-的四種常見類型">Gate 的四種常見類型</h2>
<h3 id="認證-gate">認證 Gate</h3>
<p>使用者必須驗證身份才能使用功能。生物辨識、密碼、PIN 碼、OAuth 登入。</p>
<p>認證 gate 的 fallback 設計取決於安全需求和使用場景。銀行 app 可能要求生物辨識 + PIN 碼雙重驗證，沒有更低層級的 fallback。自用工具可以接受密碼 fallback，因為使用者本身就是 owner — 可用性優先於認證強度（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h3 id="網路-gate">網路 Gate</h3>
<p>功能需要網路連線才能運作。連線存在但不穩定的場景比完全離線更難處理 — 請求可能成功、可能逾時、可能部分成功。</p>
<h3 id="權限-gate">權限 Gate</h3>
<p>App 需要系統權限（相機、位置、通知）才能使用特定功能。</p>
<p>權限 gate 的特殊性在於使用者可以永久拒絕。拒絕後再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。</p>
<h3 id="環境-gate">環境 Gate</h3>
<p>特定的硬體或軟體條件必須滿足。最低 OS 版本、特定感測器（NFC、深度相機）、特定連接（藍牙已開啟）。</p>
<p>環境 gate 的 fallback 通常有限 — 硬體不存在時無法用軟體模擬。但至少應該告知使用者為什麼功能不可用，而非靜默禁用。</p>
<h3 id="其他常見-gate">其他常見 Gate</h3>
<p>商業 app 還有兩種 gate 在本系列涵蓋範圍之外但實務常見：</p>
<p><strong>付費 Gate</strong>（paywall）：功能需要付費才能使用。付費 gate 的 fallback 設計和上述四種不同 — 「失敗」路徑的目標是引導使用者付費而非提供替代功能。試用期、降級功能、付費引導 vs 付費強制的取捨依賴商業模式決策。</p>
<p><strong>版本相容性 Gate</strong>：API 版本過舊需要升級 app。Fallback 是提示使用者更新，但強制更新會阻擋無法更新的使用者（舊 OS 版本不支援新版 app）。</p>
<h2 id="gate-設計表">Gate 設計表</h2>
<p>把三問設計法應用到每個 gate，產出一張設計表：</p>
<table>
  <thead>
      <tr>
          <th>Gate</th>
          <th>成功</th>
          <th>失敗</th>
          <th>不確定</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>生物辨識</td>
          <td>進入主畫面</td>
          <td>提示輸入裝置密碼</td>
          <td>顯示「驗證中」</td>
      </tr>
      <tr>
          <td>網路連線</td>
          <td>開始載入資料</td>
          <td>顯示離線提示 + 重試</td>
          <td>顯示 loading + 取消</td>
      </tr>
      <tr>
          <td>相機權限</td>
          <td>開啟掃描功能</td>
          <td>說明原因 + 設定連結</td>
          <td>等待系統對話框</td>
      </tr>
      <tr>
          <td>藍牙</td>
          <td>開始裝置搜尋</td>
          <td>提示開啟藍牙 + 連結</td>
          <td>顯示搜尋中 + 取消</td>
      </tr>
  </tbody>
</table>
<p>失敗欄和不確定欄為空的 gate 就是 UX 死胡同的候選 — 和<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a>的退出路徑檢查同樣的邏輯。</p>
<p>三問設計法的具體應用在 <a href="/blog/ux-design/02-gate-fallback/biometric-fallback-design/" data-link-title="Biometric fallback 完整設計" data-link-desc="iOS Face ID / Touch ID 和 Android BiometricPrompt 的行為差異、fallback 策略、安全 vs 可用性取捨的顯式記錄方法">Biometric fallback 完整設計</a>中以生物辨識 gate 為例展開。Gate 在開發環境的行為可能和真機不同，<a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a>列出每個 gate 在模擬器和真機上的差異。Gate 設計表的「失敗」欄和<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">畫面狀態矩陣</a>的「退出路徑」欄是同一個問題在不同層級的表達。</p>
]]></content:encoded></item><item><title>Mobile 導航模式分類</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/</guid><description>&lt;p>Mobile 導航模式決定使用者如何在畫面之間移動。每種模式對應不同的使用者心理模型 — 使用者期望按 back 會發生什麼、期望首頁在哪裡、期望平行功能如何切換。選擇導航模式的依據是 app 的資訊架構和使用者的操作路徑。&lt;/p>
&lt;h2 id="pushpop-stack堆疊導航">Push/pop stack（堆疊導航）&lt;/h2>
&lt;p>堆疊導航是最基本的模式。每次導航把新畫面推入堆疊頂端，按 back 彈出頂端畫面回到前一頁。使用者的心理模型是「深入 → 返回」的線性路徑。&lt;/p>
&lt;p>適合場景：層級式的資訊結構（列表 → 詳細 → 編輯）、步驟式流程（填表 → 確認 → 完成）。&lt;/p>
&lt;p>堆疊導航的限制是「只有一條軸」— 使用者只能在深度方向移動（往下鑽或往上回），無法在同層級的平行功能之間橫向切換。&lt;/p>
&lt;h2 id="declarative-router宣告式路由">Declarative router（宣告式路由）&lt;/h2>
&lt;p>Declarative router 用 URL 或路由路徑表示畫面狀態。Flutter 的 GoRouter、React Router、Vue Router 都屬於這個模式。導航操作是「把 URL 設成 /settings」而非「push SettingsScreen」。&lt;/p>
&lt;p>Declarative router 的優勢是路由狀態和畫面狀態分離 — 路由邏輯集中管理，支援 deep link，支援動態重建導航堆疊（例如從 deep link 恢復完整的 back 堆疊）。&lt;/p>
&lt;p>適合場景：需要 deep link 支援的 app、URL 驅動的 web app、複雜的條件式導航（根據使用者狀態決定顯示哪個畫面）。&lt;/p>
&lt;h2 id="tab-bar標籤列導航">Tab bar（標籤列導航）&lt;/h2>
&lt;p>畫面底部的標籤列讓使用者在平行的頂層功能之間橫向切換。每個 tab 是獨立的導航堆疊 — 在 tab A 深入到第三層，切換到 tab B 再切回 tab A，回到 tab A 的第三層。&lt;/p>
&lt;p>適合場景：3-5 個平行的主要功能（首頁、搜尋、通知、個人檔案）。使用者頻繁在這些功能之間切換。&lt;/p>
&lt;p>Tab bar 的限制是 tab 數量。超過 5 個 tab 在手機螢幕上過於擁擠。超過 5 個頂層功能時，次要功能放進「更多」tab 或改用 drawer。&lt;/p>
&lt;h2 id="drawer抽屜導航">Drawer（抽屜導航）&lt;/h2>
&lt;p>從螢幕邊緣滑出的側邊選單，列出所有導航選項。使用者需要打開 drawer 才能看到選項，日常操作中 drawer 是隱藏的。&lt;/p>
&lt;p>適合場景：頂層功能超過 5 個、功能之間的切換頻率低、或需要顯示使用者資訊（帳號、設定）。&lt;/p>
&lt;p>Drawer 的缺點是功能的可見性低 — 隱藏在側邊的功能不如 tab bar 上的功能容易被發現。不常用的功能適合放 drawer，核心功能應該放在更可見的位置。&lt;/p>
&lt;h2 id="組合使用">組合使用&lt;/h2>
&lt;p>多數 app 組合使用多種導航模式。Tab bar 做頂層橫向導航，每個 tab 內部用 push/pop 做縱向深入，drawer 放使用者設定和次要功能。&lt;/p>
&lt;p>組合使用時的注意點：back 按鈕的行為在不同模式下需要一致。在 tab A 的第三層按 back 應該回到第二層（push/pop 行為），而非切換到上一個 tab。&lt;/p>
&lt;p>分類建立後，在 Flutter 的實作層面用 &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">GoRouter 導航設計&lt;/a>把導航模式對應到具體的 router 設定。不同導航操作（go / push / pushReplacement）的 UX 語意差異在 &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push vs pushReplacement 語意表&lt;/a>中逐一比對。確認導航實作正確後，用&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性檢查&lt;/a>驗證所有路由從 UI 可達。&lt;/p></description><content:encoded><![CDATA[<p>Mobile 導航模式決定使用者如何在畫面之間移動。每種模式對應不同的使用者心理模型 — 使用者期望按 back 會發生什麼、期望首頁在哪裡、期望平行功能如何切換。選擇導航模式的依據是 app 的資訊架構和使用者的操作路徑。</p>
<h2 id="pushpop-stack堆疊導航">Push/pop stack（堆疊導航）</h2>
<p>堆疊導航是最基本的模式。每次導航把新畫面推入堆疊頂端，按 back 彈出頂端畫面回到前一頁。使用者的心理模型是「深入 → 返回」的線性路徑。</p>
<p>適合場景：層級式的資訊結構（列表 → 詳細 → 編輯）、步驟式流程（填表 → 確認 → 完成）。</p>
<p>堆疊導航的限制是「只有一條軸」— 使用者只能在深度方向移動（往下鑽或往上回），無法在同層級的平行功能之間橫向切換。</p>
<h2 id="declarative-router宣告式路由">Declarative router（宣告式路由）</h2>
<p>Declarative router 用 URL 或路由路徑表示畫面狀態。Flutter 的 GoRouter、React Router、Vue Router 都屬於這個模式。導航操作是「把 URL 設成 /settings」而非「push SettingsScreen」。</p>
<p>Declarative router 的優勢是路由狀態和畫面狀態分離 — 路由邏輯集中管理，支援 deep link，支援動態重建導航堆疊（例如從 deep link 恢復完整的 back 堆疊）。</p>
<p>適合場景：需要 deep link 支援的 app、URL 驅動的 web app、複雜的條件式導航（根據使用者狀態決定顯示哪個畫面）。</p>
<h2 id="tab-bar標籤列導航">Tab bar（標籤列導航）</h2>
<p>畫面底部的標籤列讓使用者在平行的頂層功能之間橫向切換。每個 tab 是獨立的導航堆疊 — 在 tab A 深入到第三層，切換到 tab B 再切回 tab A，回到 tab A 的第三層。</p>
<p>適合場景：3-5 個平行的主要功能（首頁、搜尋、通知、個人檔案）。使用者頻繁在這些功能之間切換。</p>
<p>Tab bar 的限制是 tab 數量。超過 5 個 tab 在手機螢幕上過於擁擠。超過 5 個頂層功能時，次要功能放進「更多」tab 或改用 drawer。</p>
<h2 id="drawer抽屜導航">Drawer（抽屜導航）</h2>
<p>從螢幕邊緣滑出的側邊選單，列出所有導航選項。使用者需要打開 drawer 才能看到選項，日常操作中 drawer 是隱藏的。</p>
<p>適合場景：頂層功能超過 5 個、功能之間的切換頻率低、或需要顯示使用者資訊（帳號、設定）。</p>
<p>Drawer 的缺點是功能的可見性低 — 隱藏在側邊的功能不如 tab bar 上的功能容易被發現。不常用的功能適合放 drawer，核心功能應該放在更可見的位置。</p>
<h2 id="組合使用">組合使用</h2>
<p>多數 app 組合使用多種導航模式。Tab bar 做頂層橫向導航，每個 tab 內部用 push/pop 做縱向深入，drawer 放使用者設定和次要功能。</p>
<p>組合使用時的注意點：back 按鈕的行為在不同模式下需要一致。在 tab A 的第三層按 back 應該回到第二層（push/pop 行為），而非切換到上一個 tab。</p>
<p>分類建立後，在 Flutter 的實作層面用 <a href="/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">GoRouter 導航設計</a>把導航模式對應到具體的 router 設定。不同導航操作（go / push / pushReplacement）的 UX 語意差異在 <a href="/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push vs pushReplacement 語意表</a>中逐一比對。確認導航實作正確後，用<a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性檢查</a>驗證所有路由從 UI 可達。</p>
]]></content:encoded></item><item><title>U.C1 Terminal 畫面五個狀態零個退出路徑</title><link>https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/</guid><description>&lt;p>這個案例的核心責任是說明「每個畫面每個狀態都需要退出路徑」這個原則為什麼容易在企劃階段被遺漏，以及用什麼工具能系統性地捕捉這類缺口。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的 Terminal 畫面用一個 &lt;code>TerminalScreenUiState&lt;/code> enum 管理五個狀態。實機測試前，五個狀態的 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;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connecting&lt;/td>
 &lt;td>進度指示&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connected&lt;/td>
 &lt;td>終端機 + 工具列&lt;/td>
 &lt;td>打字、特殊鍵&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>錯誤訊息 + 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>disconnected&lt;/td>
 &lt;td>「連線中斷」+ 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>使用者從首頁點 Connect Terminal 進入後，無論處於哪個狀態都無法返回首頁。唯一退出方式是殺掉 app。&lt;/p>
&lt;p>W2-001 修復後加入 back 按鈕的狀態：error、disconnected、connecting。但 idle 和 connected 仍缺退出路徑。&lt;/p>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>企劃文件的「前端引導」欄位只描述顯示，不描述操作和退出&lt;/strong>。操作盤點表的「前端引導」欄位寫了「連線失敗顯示無法連線」— 覆蓋了 error 狀態的顯示，但沒回答「能做什麼」和「怎麼離開」。從 BDD 操作盤點到 UI 實作之間，缺少把「情境」展開成「畫面 × 狀態 × 操作 × 退出」矩陣的步驟。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>開發者假設使用者只走 happy path&lt;/strong>。「connected 後使用者不會想回首頁」是開發者的隱性假設。實際上使用者可能想：切換到配對畫面重新配對、暫時離開終端機做其他事、遇到問題想重新開始。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>error 和 disconnected 有重連按鈕但沒有 back，也是半成品&lt;/strong>。重連失敗時使用者被困在 error → retry → error 的循環裡。加 back 按鈕讓使用者有第二條路。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>畫面狀態矩陣作為設計產物&lt;/strong>：把每個畫面的每個狀態展開成四欄表格（顯示 / 可用操作 / 進入條件 / 退出路徑）。退出路徑欄位為空 = UX 死胡同，10 分鐘能查完所有畫面。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>退出路徑是預設要求&lt;/strong>：每個畫面的每個狀態至少要有一條退出路徑。即使是 connecting 這種過渡狀態，使用者也應該能取消。這跟 iOS HIG 和 Material Design 對 modal 畫面的 dismiss 要求一致。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Widget test 覆蓋退出路徑&lt;/strong>：狀態矩陣直接轉成 test case — 每個狀態找到 back 按鈕、tap、斷言導航到首頁。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想用狀態矩陣設計畫面 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法&lt;/a>&lt;/li>
&lt;li>想建 widget test 覆蓋導航 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">模組四：自動化 UI 驗證&lt;/a>&lt;/li>
&lt;li>類似案例（Gate fallback）→ &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「每個畫面每個狀態都需要退出路徑」這個原則為什麼容易在企劃階段被遺漏，以及用什麼工具能系統性地捕捉這類缺口。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的 Terminal 畫面用一個 <code>TerminalScreenUiState</code> enum 管理五個狀態。實機測試前，五個狀態的 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>無</td>
      </tr>
      <tr>
          <td>connecting</td>
          <td>進度指示</td>
          <td>無</td>
          <td>無</td>
      </tr>
      <tr>
          <td>connected</td>
          <td>終端機 + 工具列</td>
          <td>打字、特殊鍵</td>
          <td>無</td>
      </tr>
      <tr>
          <td>error</td>
          <td>錯誤訊息 + 重連按鈕</td>
          <td>重新連線</td>
          <td>無</td>
      </tr>
      <tr>
          <td>disconnected</td>
          <td>「連線中斷」+ 重連按鈕</td>
          <td>重新連線</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>使用者從首頁點 Connect Terminal 進入後，無論處於哪個狀態都無法返回首頁。唯一退出方式是殺掉 app。</p>
<p>W2-001 修復後加入 back 按鈕的狀態：error、disconnected、connecting。但 idle 和 connected 仍缺退出路徑。</p>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>企劃文件的「前端引導」欄位只描述顯示，不描述操作和退出</strong>。操作盤點表的「前端引導」欄位寫了「連線失敗顯示無法連線」— 覆蓋了 error 狀態的顯示，但沒回答「能做什麼」和「怎麼離開」。從 BDD 操作盤點到 UI 實作之間，缺少把「情境」展開成「畫面 × 狀態 × 操作 × 退出」矩陣的步驟。</p>
</li>
<li>
<p><strong>開發者假設使用者只走 happy path</strong>。「connected 後使用者不會想回首頁」是開發者的隱性假設。實際上使用者可能想：切換到配對畫面重新配對、暫時離開終端機做其他事、遇到問題想重新開始。</p>
</li>
<li>
<p><strong>error 和 disconnected 有重連按鈕但沒有 back，也是半成品</strong>。重連失敗時使用者被困在 error → retry → error 的循環裡。加 back 按鈕讓使用者有第二條路。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li>
<p><strong>畫面狀態矩陣作為設計產物</strong>：把每個畫面的每個狀態展開成四欄表格（顯示 / 可用操作 / 進入條件 / 退出路徑）。退出路徑欄位為空 = UX 死胡同，10 分鐘能查完所有畫面。</p>
</li>
<li>
<p><strong>退出路徑是預設要求</strong>：每個畫面的每個狀態至少要有一條退出路徑。即使是 connecting 這種過渡狀態，使用者也應該能取消。這跟 iOS HIG 和 Material Design 對 modal 畫面的 dismiss 要求一致。</p>
</li>
<li>
<p><strong>Widget test 覆蓋退出路徑</strong>：狀態矩陣直接轉成 test case — 每個狀態找到 back 按鈕、tap、斷言導航到首頁。</p>
</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想用狀態矩陣設計畫面 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</a></li>
<li>想建 widget test 覆蓋導航 → <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">模組四：自動化 UI 驗證</a></li>
<li>類似案例（Gate fallback）→ <a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback</a></li>
</ul>
]]></content:encoded></item><item><title>畫面狀態矩陣</title><link>https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/</guid><description>&lt;p>畫面狀態矩陣的核心概念是「用結構化表格讓每個畫面狀態的退出路徑可見」。每行代表一個畫面的一個狀態，四欄分別記錄該狀態的顯示內容、使用者可用操作、進入條件和退出路徑。退出路徑欄位為空代表 UX 死胡同 — 使用者進入後無法靠自己的操作離開。可先對照 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>畫面狀態矩陣位在 BDD 操作盤點和 UI 實作之間。操作盤點描述「使用者做什麼、看到什麼」，畫面狀態矩陣把這些描述展開成每個狀態的四個面向，補上操作盤點容易遺漏的「可用操作」和「退出路徑」。矩陣產出後可以直接轉成 widget test case，也可以加上「可觀測性」欄位連接 log 設計。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要畫面狀態矩陣的訊號是實機測試時發現使用者被困在某個畫面出不去。常見情境：error 畫面只有重連按鈕沒有返回按鈕、loading 畫面沒有取消操作、connected 畫面沒有斷線或返回的出口。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>畫面狀態矩陣的設計責任是在實作前暴露導航缺口。填寫時要確保每個狀態至少有一條退出路徑，即使是 connecting 這種過渡狀態也應該提供取消操作。矩陣和 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate&lt;/a> 設計互補 — gate 的失敗路徑和不確定路徑應該反映在矩陣的退出路徑欄中。&lt;/p></description><content:encoded><![CDATA[<p>畫面狀態矩陣的核心概念是「用結構化表格讓每個畫面狀態的退出路徑可見」。每行代表一個畫面的一個狀態，四欄分別記錄該狀態的顯示內容、使用者可用操作、進入條件和退出路徑。退出路徑欄位為空代表 UX 死胡同 — 使用者進入後無法靠自己的操作離開。可先對照 <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>畫面狀態矩陣位在 BDD 操作盤點和 UI 實作之間。操作盤點描述「使用者做什麼、看到什麼」，畫面狀態矩陣把這些描述展開成每個狀態的四個面向，補上操作盤點容易遺漏的「可用操作」和「退出路徑」。矩陣產出後可以直接轉成 widget test case，也可以加上「可觀測性」欄位連接 log 設計。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要畫面狀態矩陣的訊號是實機測試時發現使用者被困在某個畫面出不去。常見情境：error 畫面只有重連按鈕沒有返回按鈕、loading 畫面沒有取消操作、connected 畫面沒有斷線或返回的出口。</p>
<h2 id="設計責任">設計責任</h2>
<p>畫面狀態矩陣的設計責任是在實作前暴露導航缺口。填寫時要確保每個狀態至少有一條退出路徑，即使是 connecting 這種過渡狀態也應該提供取消操作。矩陣和 <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate</a> 設計互補 — gate 的失敗路徑和不確定路徑應該反映在矩陣的退出路徑欄中。</p>
]]></content:encoded></item><item><title>畫面狀態矩陣的定義與填寫方法</title><link>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/</guid><description>&lt;p>&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>是一張表格，每行代表一個畫面的一個狀態，每列描述該狀態的四個面向：顯示了什麼、使用者能做什麼操作、什麼條件進入這個狀態、怎麼離開。這張表格的目的是在實作前暴露導航缺口 — 退出路徑欄位為空代表使用者一旦進入就出不去。&lt;/p>
&lt;h2 id="四欄定義">四欄定義&lt;/h2>
&lt;h3 id="顯示">顯示&lt;/h3>
&lt;p>使用者看到的畫面元素。進度指示器、錯誤訊息、表單欄位、資料列表 — 任何視覺上呈現的內容。&lt;/p>
&lt;h3 id="可用操作">可用操作&lt;/h3>
&lt;p>使用者在這個狀態下能執行的動作。按鈕、手勢、鍵盤輸入、下拉選單選擇。重點是「能做什麼」，不是「看到什麼」。&lt;/p>
&lt;p>「顯示」和「可用操作」的區別在於互動性。顯示一段錯誤訊息和顯示一個重試按鈕都是「顯示」；按下重試按鈕觸發重新連線是「可用操作」。&lt;/p>
&lt;h3 id="進入條件">進入條件&lt;/h3>
&lt;p>什麼事件或動作讓畫面進入這個狀態。使用者操作（點擊按鈕）、系統事件（連線成功）、外部事件（推播通知）。&lt;/p>
&lt;h3 id="退出路徑">退出路徑&lt;/h3>
&lt;p>使用者如何離開這個狀態。返回上一頁、導航到另一個畫面、取消當前操作、完成流程後自動轉場。&lt;/p>
&lt;p>退出路徑是這張表格中最容易遺漏的欄位。開發者設計畫面時，注意力集中在「進來後看到什麼、能做什麼」，容易忽略「怎麼離開」。&lt;/p>
&lt;h2 id="填寫步驟">填寫步驟&lt;/h2>
&lt;h3 id="第一步列出畫面的所有狀態">第一步：列出畫面的所有狀態&lt;/h3>
&lt;p>從程式碼中的狀態管理機制取得狀態清單。Flutter 的 enum、React 的 state、Vue 的 reactive data — 任何控制畫面呈現的狀態變數。&lt;/p>
&lt;p>如果沒有明確的狀態 enum，從畫面的視覺變化反推：loading 時長什麼樣、成功時長什麼樣、失敗時長什麼樣。每種不同的視覺呈現就是一個狀態。&lt;/p>
&lt;h3 id="第二步每個狀態填四欄">第二步：每個狀態填四欄&lt;/h3>
&lt;p>逐一填寫每個狀態的顯示、可用操作、進入條件、退出路徑。填不出來的欄位先留空，留空本身就是發現。&lt;/p>
&lt;h3 id="第三步檢查退出路徑欄">第三步：檢查退出路徑欄&lt;/h3>
&lt;p>退出路徑為空的狀態是 UX 死胡同。使用者進入後無法靠自己的操作離開，只能殺掉 app 或等系統逾時。&lt;/p>
&lt;p>app_tunnel 的 Terminal 畫面有五個 enum 狀態（idle / connecting / connected / error / disconnected），每個狀態的退出路徑都是空的。使用者從首頁點 Connect Terminal 進入後，無論處於哪個狀態都無法返回首頁（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>）。10 分鐘填完這張表格就能發現全部五個缺口（本章合成，UF-3 Derive）。&lt;/p>
&lt;h3 id="第四步檢查操作欄">第四步：檢查操作欄&lt;/h3>
&lt;p>操作欄為空的狀態可能合理（loading 時使用者等待），也可能代表缺少互動設計。loading 狀態通常應該有「取消」操作，error 狀態通常應該有「重試」和「返回」。&lt;/p>
&lt;h2 id="填寫範例">填寫範例&lt;/h2>
&lt;p>以 app_tunnel Terminal 畫面為例，修復前的矩陣如下：&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>idle&lt;/td>
 &lt;td>空白（自動連線）&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>進入畫面&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connecting&lt;/td>
 &lt;td>進度指示&lt;/td>
 &lt;td>無&lt;/td>
 &lt;td>idle 自動觸發&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connected&lt;/td>
 &lt;td>終端機 + 工具列&lt;/td>
 &lt;td>打字、特殊鍵&lt;/td>
 &lt;td>WebSocket 連線成功&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>錯誤訊息 + 重連按鈕&lt;/td>
 &lt;td>重新連線&lt;/td>
 &lt;td>連線失敗&lt;/td>
 &lt;td>無&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>disconnected&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>退出路徑欄全空 — 五個 UX 死胡同。修復後的矩陣應該每個狀態都有至少一條退出路徑：&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>idle&lt;/td>
 &lt;td>空白（自動連線）&lt;/td>
 &lt;td>取消&lt;/td>
 &lt;td>進入畫面&lt;/td>
 &lt;td>取消 → 返回首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connecting&lt;/td>
 &lt;td>進度指示 + back 按鈕&lt;/td>
 &lt;td>取消連線&lt;/td>
 &lt;td>idle 自動觸發&lt;/td>
 &lt;td>back → 返回首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>connected&lt;/td>
 &lt;td>終端機 + 工具列 + back&lt;/td>
 &lt;td>打字、特殊鍵、中斷連線&lt;/td>
 &lt;td>WebSocket 連線成功&lt;/td>
 &lt;td>back / disconnect → 首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>error&lt;/td>
 &lt;td>錯誤訊息 + 重連 + back&lt;/td>
 &lt;td>重新連線、返回&lt;/td>
 &lt;td>連線失敗&lt;/td>
 &lt;td>back → 返回首頁&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>disconnected&lt;/td>
 &lt;td>中斷訊息 + 重連 + back&lt;/td>
 &lt;td>重新連線、返回&lt;/td>
 &lt;td>連線斷開&lt;/td>
 &lt;td>back → 返回首頁&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="每個狀態至少一條退出路徑">每個狀態至少一條退出路徑&lt;/h2>
&lt;p>退出路徑是預設要求。即使是 connecting 這種過渡狀態，使用者也應該能取消 — 連線卡住時使用者需要能離開。iOS HIG 和 Material Design 對 modal 畫面都要求 dismiss 機制；畫面狀態矩陣的退出路徑欄是這個要求的具體檢查方式。&lt;/p></description><content:encoded><![CDATA[<p><a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>是一張表格，每行代表一個畫面的一個狀態，每列描述該狀態的四個面向：顯示了什麼、使用者能做什麼操作、什麼條件進入這個狀態、怎麼離開。這張表格的目的是在實作前暴露導航缺口 — 退出路徑欄位為空代表使用者一旦進入就出不去。</p>
<h2 id="四欄定義">四欄定義</h2>
<h3 id="顯示">顯示</h3>
<p>使用者看到的畫面元素。進度指示器、錯誤訊息、表單欄位、資料列表 — 任何視覺上呈現的內容。</p>
<h3 id="可用操作">可用操作</h3>
<p>使用者在這個狀態下能執行的動作。按鈕、手勢、鍵盤輸入、下拉選單選擇。重點是「能做什麼」，不是「看到什麼」。</p>
<p>「顯示」和「可用操作」的區別在於互動性。顯示一段錯誤訊息和顯示一個重試按鈕都是「顯示」；按下重試按鈕觸發重新連線是「可用操作」。</p>
<h3 id="進入條件">進入條件</h3>
<p>什麼事件或動作讓畫面進入這個狀態。使用者操作（點擊按鈕）、系統事件（連線成功）、外部事件（推播通知）。</p>
<h3 id="退出路徑">退出路徑</h3>
<p>使用者如何離開這個狀態。返回上一頁、導航到另一個畫面、取消當前操作、完成流程後自動轉場。</p>
<p>退出路徑是這張表格中最容易遺漏的欄位。開發者設計畫面時，注意力集中在「進來後看到什麼、能做什麼」，容易忽略「怎麼離開」。</p>
<h2 id="填寫步驟">填寫步驟</h2>
<h3 id="第一步列出畫面的所有狀態">第一步：列出畫面的所有狀態</h3>
<p>從程式碼中的狀態管理機制取得狀態清單。Flutter 的 enum、React 的 state、Vue 的 reactive data — 任何控制畫面呈現的狀態變數。</p>
<p>如果沒有明確的狀態 enum，從畫面的視覺變化反推：loading 時長什麼樣、成功時長什麼樣、失敗時長什麼樣。每種不同的視覺呈現就是一個狀態。</p>
<h3 id="第二步每個狀態填四欄">第二步：每個狀態填四欄</h3>
<p>逐一填寫每個狀態的顯示、可用操作、進入條件、退出路徑。填不出來的欄位先留空，留空本身就是發現。</p>
<h3 id="第三步檢查退出路徑欄">第三步：檢查退出路徑欄</h3>
<p>退出路徑為空的狀態是 UX 死胡同。使用者進入後無法靠自己的操作離開，只能殺掉 app 或等系統逾時。</p>
<p>app_tunnel 的 Terminal 畫面有五個 enum 狀態（idle / connecting / connected / error / disconnected），每個狀態的退出路徑都是空的。使用者從首頁點 Connect Terminal 進入後，無論處於哪個狀態都無法返回首頁（<a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a>）。10 分鐘填完這張表格就能發現全部五個缺口（本章合成，UF-3 Derive）。</p>
<h3 id="第四步檢查操作欄">第四步：檢查操作欄</h3>
<p>操作欄為空的狀態可能合理（loading 時使用者等待），也可能代表缺少互動設計。loading 狀態通常應該有「取消」操作，error 狀態通常應該有「重試」和「返回」。</p>
<h2 id="填寫範例">填寫範例</h2>
<p>以 app_tunnel Terminal 畫面為例，修復前的矩陣如下：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>顯示</th>
          <th>可用操作</th>
          <th>進入條件</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idle</td>
          <td>空白（自動連線）</td>
          <td>無</td>
          <td>進入畫面</td>
          <td>無</td>
      </tr>
      <tr>
          <td>connecting</td>
          <td>進度指示</td>
          <td>無</td>
          <td>idle 自動觸發</td>
          <td>無</td>
      </tr>
      <tr>
          <td>connected</td>
          <td>終端機 + 工具列</td>
          <td>打字、特殊鍵</td>
          <td>WebSocket 連線成功</td>
          <td>無</td>
      </tr>
      <tr>
          <td>error</td>
          <td>錯誤訊息 + 重連按鈕</td>
          <td>重新連線</td>
          <td>連線失敗</td>
          <td>無</td>
      </tr>
      <tr>
          <td>disconnected</td>
          <td>「連線中斷」+ 重連按鈕</td>
          <td>重新連線</td>
          <td>連線斷開</td>
          <td>無</td>
      </tr>
  </tbody>
</table>
<p>退出路徑欄全空 — 五個 UX 死胡同。修復後的矩陣應該每個狀態都有至少一條退出路徑：</p>
<table>
  <thead>
      <tr>
          <th>狀態</th>
          <th>顯示</th>
          <th>可用操作</th>
          <th>進入條件</th>
          <th>退出路徑</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>idle</td>
          <td>空白（自動連線）</td>
          <td>取消</td>
          <td>進入畫面</td>
          <td>取消 → 返回首頁</td>
      </tr>
      <tr>
          <td>connecting</td>
          <td>進度指示 + back 按鈕</td>
          <td>取消連線</td>
          <td>idle 自動觸發</td>
          <td>back → 返回首頁</td>
      </tr>
      <tr>
          <td>connected</td>
          <td>終端機 + 工具列 + back</td>
          <td>打字、特殊鍵、中斷連線</td>
          <td>WebSocket 連線成功</td>
          <td>back / disconnect → 首頁</td>
      </tr>
      <tr>
          <td>error</td>
          <td>錯誤訊息 + 重連 + back</td>
          <td>重新連線、返回</td>
          <td>連線失敗</td>
          <td>back → 返回首頁</td>
      </tr>
      <tr>
          <td>disconnected</td>
          <td>中斷訊息 + 重連 + back</td>
          <td>重新連線、返回</td>
          <td>連線斷開</td>
          <td>back → 返回首頁</td>
      </tr>
  </tbody>
</table>
<h2 id="每個狀態至少一條退出路徑">每個狀態至少一條退出路徑</h2>
<p>退出路徑是預設要求。即使是 connecting 這種過渡狀態，使用者也應該能取消 — 連線卡住時使用者需要能離開。iOS HIG 和 Material Design 對 modal 畫面都要求 dismiss 機制；畫面狀態矩陣的退出路徑欄是這個要求的具體檢查方式。</p>
<p>退出路徑為空只在一種情況下合理：該狀態持續時間極短（&lt; 1 秒）且有保證的自動轉場。即使如此，仍建議保留取消操作 — 因為「極短」是在正常情況下的預期，異常情況（網路中斷、服務當機）可能讓這個狀態停留很久。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>從 BDD 操作盤點展開到狀態矩陣 → <a href="/blog/ux-design/01-screen-state-machine/bdd-to-state-matrix/" data-link-title="從 BDD 操作盤點展開到狀態矩陣" data-link-desc="五步驟把 BDD 操作盤點的「前端引導」展開成完整的畫面狀態矩陣 — 補上操作和退出這兩個容易遺漏的面向">從 BDD 操作盤點展開到狀態矩陣</a></li>
<li>路由可達性檢查 → <a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性檢查</a></li>
<li>狀態矩陣轉 widget test case → <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 UI 自動化</a></li>
<li>狀態矩陣加「可觀測性」欄位 → <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二 客戶端可觀測性</a></li>
<li>狀態轉換事件作為 funnel 分析原料 → <a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">monitoring 模組八 行為資料的商業利用</a></li>
</ul>
]]></content:encoded></item><item><title>模組一：畫面狀態機設計</title><link>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/</guid><description>&lt;p>回答「這個畫面有幾個狀態、每個狀態能做什麼、怎麼離開」。核心工具是畫面狀態矩陣。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UF-1&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>&lt;/td>
 &lt;td>5 enum 狀態 0 退出路徑&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UF-2&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>&lt;/td>
 &lt;td>操作盤點「前端引導」只描述顯示不描述操作和退出 — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UF-3&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>&lt;/td>
 &lt;td>畫面狀態矩陣能快速暴露導航缺口&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UF-9&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>&lt;/td>
 &lt;td>路由存在但 UI 不可達 = 死程式碼的 UX 版本&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 畫面狀態矩陣的定義與填寫方法&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 從 BDD 操作盤點展開到狀態矩陣的五步驟&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 路由可達性檢查（router 定義的路由 vs UI 可達的路由）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 反模式：假設使用者只走 happy path&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 UI 自動化&lt;/a>：狀態矩陣轉 widget test case&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二 客戶端可觀測性&lt;/a>：狀態矩陣可加「可觀測性」欄位&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">monitoring 模組八 商業利用&lt;/a>：狀態轉換事件是 funnel 分析的原料&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略&lt;/a>：三層測試中 screen state test 對應狀態矩陣&lt;/li>
&lt;li>← work-log：&lt;a href="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/" data-link-title="每個畫面都需要出口：畫面狀態機設計與 UX 導航的系統性方法" data-link-desc="實機測到某畫面沒有返回或退出按鈕、使用者被困住。根因是企劃沒系統列出每個畫面的狀態與可用操作；用畫面狀態矩陣確保每個狀態都有明確出口。">每個畫面都需要出口&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「這個畫面有幾個狀態、每個狀態能做什麼、怎麼離開」。核心工具是畫面狀態矩陣。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UF-1</td>
          <td><a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a></td>
          <td>5 enum 狀態 0 退出路徑</td>
      </tr>
      <tr>
          <td>UF-2</td>
          <td><a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a></td>
          <td>操作盤點「前端引導」只描述顯示不描述操作和退出 — <strong>本模組主寫</strong></td>
      </tr>
      <tr>
          <td>UF-3</td>
          <td><a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a></td>
          <td>畫面狀態矩陣能快速暴露導航缺口</td>
      </tr>
      <tr>
          <td>UF-9</td>
          <td><a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a></td>
          <td>路由存在但 UI 不可達 = 死程式碼的 UX 版本</td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 畫面狀態矩陣的定義與填寫方法</li>
<li><input checked="" disabled="" type="checkbox"> 從 BDD 操作盤點展開到狀態矩陣的五步驟</li>
<li><input checked="" disabled="" type="checkbox"> 路由可達性檢查（router 定義的路由 vs UI 可達的路由）</li>
<li><input checked="" disabled="" type="checkbox"> 反模式：假設使用者只走 happy path</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 UI 自動化</a>：狀態矩陣轉 widget test case</li>
<li>→ <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">testing 模組二 客戶端可觀測性</a>：狀態矩陣可加「可觀測性」欄位</li>
<li>→ <a href="/blog/monitoring/08-business-analytics/" data-link-title="模組八：行為資料的商業利用" data-link-desc="Funnel / Cohort / Attribution / A/B test / 推薦系統 / RFM — 從 debug 工具到商業資產的翻轉">monitoring 模組八 商業利用</a>：狀態轉換事件是 funnel 分析的原料</li>
<li>← <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略</a>：三層測試中 screen state test 對應狀態矩陣</li>
<li>← work-log：<a href="/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/" data-link-title="每個畫面都需要出口：畫面狀態機設計與 UX 導航的系統性方法" data-link-desc="實機測到某畫面沒有返回或退出按鈕、使用者被困住。根因是企劃沒系統列出每個畫面的狀態與可用操作；用畫面狀態矩陣確保每個狀態都有明確出口。">每個畫面都需要出口</a></li>
</ul>
]]></content:encoded></item><item><title>輸入機制決策表</title><link>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/four-dimension-decision/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/four-dimension-decision/</guid><description>&lt;p>輸入機制是設計產物，在功能規格階段決定，和 API schema、畫面狀態矩陣同級。手機鍵盤的行為由多個參數控制，每個參數都是一個設計決策，影響使用者體驗、UI layout 和通訊協議。&lt;/p>
&lt;h2 id="四個決策維度">四個決策維度&lt;/h2>
&lt;h3 id="keyboard-type顯示哪種鍵盤">Keyboard type：顯示哪種鍵盤&lt;/h3>
&lt;p>Keyboard type 決定使用者按下輸入框時出現什麼鍵盤。數字鍵盤、email 鍵盤、URL 鍵盤、一般文字鍵盤 — 每種鍵盤的按鍵配置和自動行為不同。&lt;/p>
&lt;p>選擇判斷依據是「使用者要輸入什麼內容」。email 地址用 email 鍵盤（有 &lt;code>@&lt;/code> 鍵），電話號碼用數字鍵盤，密碼或 CLI 指令用 &lt;code>visiblePassword&lt;/code> 型別（避免自動校正和建議）。&lt;/p>
&lt;p>app_tunnel 的 terminal 輸入框用 &lt;code>TextInputType.visiblePassword&lt;/code> — 因為 CLI 指令包含路徑分隔符、flag 縮寫等非自然語言內容，一般文字鍵盤會嘗試自動校正 &lt;code>ls -la&lt;/code> 或 &lt;code>/usr/bin/&lt;/code> 成其他東西（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a>）。&lt;/p>
&lt;h3 id="submit-model怎麼送出輸入">Submit model：怎麼送出輸入&lt;/h3>
&lt;p>Submit model 決定使用者輸入的內容何時傳送給系統。兩個基本選項：整行送出（使用者按 Enter/Send 後一次傳送整行）和逐字元送出（每個按鍵即時傳送）。&lt;/p>
&lt;p>這個決策直接影響通訊協議設計（本章合成，UF-8 Derive）。整行送出代表每次傳送一個完整指令字串（&lt;code>ls -la\n&lt;/code>），server 端按行處理。逐字元送出代表每個按鍵產生一個 WebSocket frame（&lt;code>l&lt;/code>、&lt;code>s&lt;/code>、&lt;code> &lt;/code>、&lt;code>-&lt;/code>、&lt;code>l&lt;/code>、&lt;code>a&lt;/code>），server 端需要處理單字元輸入，包括 Tab 補全和 Ctrl+C 這類立即回應的操作。&lt;/p>
&lt;p>app_tunnel 選擇整行送出（&lt;code>onSubmitted&lt;/code>），代表 Tab 補全在 client 端無法觸發（因為 Tab 不會單獨送出），但實作成本較低且協議設計較簡單。逐字元送出支援 Tab 補全和命令編輯，但 protocol 複雜度顯著提高。&lt;/p>
&lt;h3 id="ime-policy輸入法的行為控制">IME policy：輸入法的行為控制&lt;/h3>
&lt;p>IME（Input Method Editor）policy 控制手機輸入法的自動行為：自動校正、建議列、個人化學習。每個行為在某些輸入場景是幫助，在另一些場景是干擾或安全風險。&lt;/p>
&lt;p>三個控制項各自有獨立的影響：&lt;/p>
&lt;ul>
&lt;li>&lt;code>autocorrect&lt;/code>：自動校正把輸入替換成字典中的詞。CLI 指令和路徑不是自然語言，自動校正會破壞輸入內容。&lt;/li>
&lt;li>&lt;code>enableSuggestions&lt;/code>：建議列在鍵盤上方顯示候選詞。在 terminal 場景中建議列遮擋畫面底部的終端機輸出。&lt;/li>
&lt;li>&lt;code>enableIMEPersonalizedLearning&lt;/code>：IME 從使用者輸入中學習新詞，跨 app 適用。CLI 輸入可能包含密碼和路徑 — 這是安全問題，見 &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">安全敏感輸入框的 IME 控制 checklist&lt;/a>。&lt;/li>
&lt;/ul>
&lt;h3 id="special-keys特殊按鍵的處理">Special keys：特殊按鍵的處理&lt;/h3>
&lt;p>手機鍵盤沒有桌面鍵盤的 Esc、Tab、Ctrl、方向鍵。如果應用需要這些按鍵，必須自建 UI 元件提供。&lt;/p>
&lt;p>app_tunnel 用底部工具列提供 Esc/Tab/Ctrl/方向鍵。這個工具列的設計（按鈕大小、排列、長按行為）是 UX 決策，不是實作細節。&lt;/p>
&lt;h2 id="決策表作為設計產物">決策表作為設計產物&lt;/h2>
&lt;p>四個維度的決策應該在功能規格中以表格形式記錄，讓 code review 時可以逐項對照實作是否符合規格。&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>Keyboard&lt;/td>
 &lt;td>visiblePassword&lt;/td>
 &lt;td>CLI 指令不適用自動校正&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Submit&lt;/td>
 &lt;td>整行送出&lt;/td>
 &lt;td>protocol 簡單，犧牲 Tab 補全&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>IME&lt;/td>
 &lt;td>全關&lt;/td>
 &lt;td>安全考量 + 非自然語言輸入&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Special keys&lt;/td>
 &lt;td>底部工具列&lt;/td>
 &lt;td>手機無實體 Esc/Tab/Ctrl&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>app_tunnel 的六個 TextField 參數全是 W2 hotfix 事後補上的，沒有一個是事前規劃。每個參數都有 gotcha — 漏掉 &lt;code>enableIMEPersonalizedLearning: false&lt;/code> 就是安全漏洞，漏掉 &lt;code>autocorrect: false&lt;/code> 就是 UX 問題。事先決策並記錄在規格中，code review 時逐項勾選，比事後逐一發現問題的成本低。&lt;/p>
&lt;p>四個維度在不同場景下的具體決策各有不同。CLI 場景的特殊需求見 &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/terminal-input-design/" data-link-title="Terminal app 輸入設計" data-link-desc="CLI 場景在手機上的特殊需求 — 非自然語言輸入、特殊按鍵需求、整行 vs 逐字元送出對 protocol 的影響">Terminal app 輸入設計&lt;/a>，安全敏感欄位的 IME 控制逐項列在 &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">IME 安全 checklist&lt;/a>。Submit model 的選擇（整行 vs 逐字元）直接影響通訊協議的設計 — 這個交叉影響在 &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試&lt;/a>中從 test 的角度分析。&lt;/p></description><content:encoded><![CDATA[<p>輸入機制是設計產物，在功能規格階段決定，和 API schema、畫面狀態矩陣同級。手機鍵盤的行為由多個參數控制，每個參數都是一個設計決策，影響使用者體驗、UI layout 和通訊協議。</p>
<h2 id="四個決策維度">四個決策維度</h2>
<h3 id="keyboard-type顯示哪種鍵盤">Keyboard type：顯示哪種鍵盤</h3>
<p>Keyboard type 決定使用者按下輸入框時出現什麼鍵盤。數字鍵盤、email 鍵盤、URL 鍵盤、一般文字鍵盤 — 每種鍵盤的按鍵配置和自動行為不同。</p>
<p>選擇判斷依據是「使用者要輸入什麼內容」。email 地址用 email 鍵盤（有 <code>@</code> 鍵），電話號碼用數字鍵盤，密碼或 CLI 指令用 <code>visiblePassword</code> 型別（避免自動校正和建議）。</p>
<p>app_tunnel 的 terminal 輸入框用 <code>TextInputType.visiblePassword</code> — 因為 CLI 指令包含路徑分隔符、flag 縮寫等非自然語言內容，一般文字鍵盤會嘗試自動校正 <code>ls -la</code> 或 <code>/usr/bin/</code> 成其他東西（<a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a>）。</p>
<h3 id="submit-model怎麼送出輸入">Submit model：怎麼送出輸入</h3>
<p>Submit model 決定使用者輸入的內容何時傳送給系統。兩個基本選項：整行送出（使用者按 Enter/Send 後一次傳送整行）和逐字元送出（每個按鍵即時傳送）。</p>
<p>這個決策直接影響通訊協議設計（本章合成，UF-8 Derive）。整行送出代表每次傳送一個完整指令字串（<code>ls -la\n</code>），server 端按行處理。逐字元送出代表每個按鍵產生一個 WebSocket frame（<code>l</code>、<code>s</code>、<code> </code>、<code>-</code>、<code>l</code>、<code>a</code>），server 端需要處理單字元輸入，包括 Tab 補全和 Ctrl+C 這類立即回應的操作。</p>
<p>app_tunnel 選擇整行送出（<code>onSubmitted</code>），代表 Tab 補全在 client 端無法觸發（因為 Tab 不會單獨送出），但實作成本較低且協議設計較簡單。逐字元送出支援 Tab 補全和命令編輯，但 protocol 複雜度顯著提高。</p>
<h3 id="ime-policy輸入法的行為控制">IME policy：輸入法的行為控制</h3>
<p>IME（Input Method Editor）policy 控制手機輸入法的自動行為：自動校正、建議列、個人化學習。每個行為在某些輸入場景是幫助，在另一些場景是干擾或安全風險。</p>
<p>三個控制項各自有獨立的影響：</p>
<ul>
<li><code>autocorrect</code>：自動校正把輸入替換成字典中的詞。CLI 指令和路徑不是自然語言，自動校正會破壞輸入內容。</li>
<li><code>enableSuggestions</code>：建議列在鍵盤上方顯示候選詞。在 terminal 場景中建議列遮擋畫面底部的終端機輸出。</li>
<li><code>enableIMEPersonalizedLearning</code>：IME 從使用者輸入中學習新詞，跨 app 適用。CLI 輸入可能包含密碼和路徑 — 這是安全問題，見 <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">安全敏感輸入框的 IME 控制 checklist</a>。</li>
</ul>
<h3 id="special-keys特殊按鍵的處理">Special keys：特殊按鍵的處理</h3>
<p>手機鍵盤沒有桌面鍵盤的 Esc、Tab、Ctrl、方向鍵。如果應用需要這些按鍵，必須自建 UI 元件提供。</p>
<p>app_tunnel 用底部工具列提供 Esc/Tab/Ctrl/方向鍵。這個工具列的設計（按鈕大小、排列、長按行為）是 UX 決策，不是實作細節。</p>
<h2 id="決策表作為設計產物">決策表作為設計產物</h2>
<p>四個維度的決策應該在功能規格中以表格形式記錄，讓 code review 時可以逐項對照實作是否符合規格。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>選項</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Keyboard</td>
          <td>visiblePassword</td>
          <td>CLI 指令不適用自動校正</td>
      </tr>
      <tr>
          <td>Submit</td>
          <td>整行送出</td>
          <td>protocol 簡單，犧牲 Tab 補全</td>
      </tr>
      <tr>
          <td>IME</td>
          <td>全關</td>
          <td>安全考量 + 非自然語言輸入</td>
      </tr>
      <tr>
          <td>Special keys</td>
          <td>底部工具列</td>
          <td>手機無實體 Esc/Tab/Ctrl</td>
      </tr>
  </tbody>
</table>
<p>app_tunnel 的六個 TextField 參數全是 W2 hotfix 事後補上的，沒有一個是事前規劃。每個參數都有 gotcha — 漏掉 <code>enableIMEPersonalizedLearning: false</code> 就是安全漏洞，漏掉 <code>autocorrect: false</code> 就是 UX 問題。事先決策並記錄在規格中，code review 時逐項勾選，比事後逐一發現問題的成本低。</p>
<p>四個維度在不同場景下的具體決策各有不同。CLI 場景的特殊需求見 <a href="/blog/ux-design/03-input-mechanism/terminal-input-design/" data-link-title="Terminal app 輸入設計" data-link-desc="CLI 場景在手機上的特殊需求 — 非自然語言輸入、特殊按鍵需求、整行 vs 逐字元送出對 protocol 的影響">Terminal app 輸入設計</a>，安全敏感欄位的 IME 控制逐項列在 <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">IME 安全 checklist</a>。Submit model 的選擇（整行 vs 逐字元）直接影響通訊協議的設計 — 這個交叉影響在 <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試</a>中從 test 的角度分析。</p>
]]></content:encoded></item><item><title>錯誤訊息撰寫原則</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-message-principles/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-message-principles/</guid><description>&lt;p>錯誤訊息承擔兩個職責：讓使用者理解發生了什麼（診斷），以及讓使用者知道下一步能做什麼（行動）。缺少診斷的訊息讓使用者焦慮（「出了什麼事？」），缺少行動的訊息讓使用者卡住（「然後呢？」）。&lt;/p>
&lt;h2 id="診斷使用者能讀懂">診斷：使用者能讀懂&lt;/h2>
&lt;h3 id="用使用者的語言描述問題">用使用者的語言描述問題&lt;/h3>
&lt;p>錯誤訊息的讀者是使用者，描述問題時使用使用者能理解的詞彙。「無法連線到伺服器」比 &lt;code>ECONNREFUSED 127.0.0.1:7681&lt;/code> 更有用。技術細節對開發者有價值，但使用者需要的是「發生了什麼影響我」。&lt;/p>
&lt;p>技術細節可以保留在次要位置 — 折疊區塊、「詳細資訊」連結、或複製到剪貼簿的按鈕。進階使用者和開發者在需要時能取得，一般使用者不被打擾。&lt;/p>
&lt;h3 id="描述影響而非原因">描述影響而非原因&lt;/h3>
&lt;p>使用者關心的是「這對我意味著什麼」。「終端機暫時無法使用」比「WebSocket 連線逾時」更直接回答使用者的問題。原因是補充資訊，影響是核心資訊。&lt;/p>
&lt;h3 id="避免技術恐嚇">避免技術恐嚇&lt;/h3>
&lt;p>「嚴重錯誤」「系統崩潰」「未知的致命例外」這類措辭讓使用者以為問題很嚴重。多數情況下使用者能做的就是重試或返回 — 訊息的語氣應該反映實際的嚴重程度。&lt;/p>
&lt;h2 id="行動使用者能做什麼">行動：使用者能做什麼&lt;/h2>
&lt;h3 id="明確列出可執行的下一步">明確列出可執行的下一步&lt;/h3>
&lt;p>每個錯誤訊息至少提供一個使用者可以執行的行動。重試、返回上一頁、檢查網路連線、聯繫支援 — 行動越具體越好。&lt;/p>
&lt;p>「發生錯誤」沒有行動指引。「連線失敗，請檢查網路後重試」提供了兩個具體行動（檢查網路、重試）。&lt;/p>
&lt;h3 id="行動要可操作">行動要可操作&lt;/h3>
&lt;p>「請聯繫系統管理員」在自用工具場景中沒有意義（使用者就是管理員）。「請稍後再試」在服務完全不可用時也沒有幫助。行動建議需要考慮使用者的實際情境和能力。&lt;/p>
&lt;h3 id="提供退出路徑">提供退出路徑&lt;/h3>
&lt;p>行動選項中至少有一個是「離開當前狀態」的退出路徑 — 返回首頁、關閉對話框、取消操作。使用者可能不想重試，只想離開。畫面狀態矩陣中退出路徑為空的狀態就是 UX 死胡同（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機&lt;/a>）。&lt;/p>
&lt;h2 id="錯誤訊息的三層結構">錯誤訊息的三層結構&lt;/h2>
&lt;p>一個完整的錯誤訊息包含三層：&lt;/p>
&lt;p>&lt;strong>標題&lt;/strong>：一句話描述影響。「終端機連線失敗」。&lt;/p>
&lt;p>&lt;strong>說明&lt;/strong>：補充原因和 context。「伺服器沒有回應，可能是網路問題或伺服器未啟動。」&lt;/p>
&lt;p>&lt;strong>行動&lt;/strong>：按鈕或連結。「重試」「返回首頁」「查看詳細資訊」。&lt;/p>
&lt;p>三層不需要都顯示。輕微錯誤（Snackbar）可以只有標題 + 一個行動按鈕。嚴重錯誤（全螢幕）三層都需要。&lt;/p>
&lt;p>錯誤訊息寫完後，使用者看到訊息的下一步通常是重試 — &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/retry-mechanism-ux/" data-link-title="Retry 機制 UX" data-link-desc="自動 vs 手動重試、指數退避 vs 立即重試 — 重試策略的選擇取決於失敗的可恢復性和使用者的等待意願">Retry 機制 UX&lt;/a> 設計重試按鈕的行為和回饋。如果重試反覆失敗，使用者需要退路而非重試迴圈，&lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口&lt;/a>提供逃生設計。Error 狀態在畫面層級的定位和退出路徑回到 &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機&lt;/a>的矩陣框架。&lt;/p></description><content:encoded><![CDATA[<p>錯誤訊息承擔兩個職責：讓使用者理解發生了什麼（診斷），以及讓使用者知道下一步能做什麼（行動）。缺少診斷的訊息讓使用者焦慮（「出了什麼事？」），缺少行動的訊息讓使用者卡住（「然後呢？」）。</p>
<h2 id="診斷使用者能讀懂">診斷：使用者能讀懂</h2>
<h3 id="用使用者的語言描述問題">用使用者的語言描述問題</h3>
<p>錯誤訊息的讀者是使用者，描述問題時使用使用者能理解的詞彙。「無法連線到伺服器」比 <code>ECONNREFUSED 127.0.0.1:7681</code> 更有用。技術細節對開發者有價值，但使用者需要的是「發生了什麼影響我」。</p>
<p>技術細節可以保留在次要位置 — 折疊區塊、「詳細資訊」連結、或複製到剪貼簿的按鈕。進階使用者和開發者在需要時能取得，一般使用者不被打擾。</p>
<h3 id="描述影響而非原因">描述影響而非原因</h3>
<p>使用者關心的是「這對我意味著什麼」。「終端機暫時無法使用」比「WebSocket 連線逾時」更直接回答使用者的問題。原因是補充資訊，影響是核心資訊。</p>
<h3 id="避免技術恐嚇">避免技術恐嚇</h3>
<p>「嚴重錯誤」「系統崩潰」「未知的致命例外」這類措辭讓使用者以為問題很嚴重。多數情況下使用者能做的就是重試或返回 — 訊息的語氣應該反映實際的嚴重程度。</p>
<h2 id="行動使用者能做什麼">行動：使用者能做什麼</h2>
<h3 id="明確列出可執行的下一步">明確列出可執行的下一步</h3>
<p>每個錯誤訊息至少提供一個使用者可以執行的行動。重試、返回上一頁、檢查網路連線、聯繫支援 — 行動越具體越好。</p>
<p>「發生錯誤」沒有行動指引。「連線失敗，請檢查網路後重試」提供了兩個具體行動（檢查網路、重試）。</p>
<h3 id="行動要可操作">行動要可操作</h3>
<p>「請聯繫系統管理員」在自用工具場景中沒有意義（使用者就是管理員）。「請稍後再試」在服務完全不可用時也沒有幫助。行動建議需要考慮使用者的實際情境和能力。</p>
<h3 id="提供退出路徑">提供退出路徑</h3>
<p>行動選項中至少有一個是「離開當前狀態」的退出路徑 — 返回首頁、關閉對話框、取消操作。使用者可能不想重試，只想離開。畫面狀態矩陣中退出路徑為空的狀態就是 UX 死胡同（<a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a>）。</p>
<h2 id="錯誤訊息的三層結構">錯誤訊息的三層結構</h2>
<p>一個完整的錯誤訊息包含三層：</p>
<p><strong>標題</strong>：一句話描述影響。「終端機連線失敗」。</p>
<p><strong>說明</strong>：補充原因和 context。「伺服器沒有回應，可能是網路問題或伺服器未啟動。」</p>
<p><strong>行動</strong>：按鈕或連結。「重試」「返回首頁」「查看詳細資訊」。</p>
<p>三層不需要都顯示。輕微錯誤（Snackbar）可以只有標題 + 一個行動按鈕。嚴重錯誤（全螢幕）三層都需要。</p>
<p>錯誤訊息寫完後，使用者看到訊息的下一步通常是重試 — <a href="/blog/ux-design/04-error-recovery/retry-mechanism-ux/" data-link-title="Retry 機制 UX" data-link-desc="自動 vs 手動重試、指數退避 vs 立即重試 — 重試策略的選擇取決於失敗的可恢復性和使用者的等待意願">Retry 機制 UX</a> 設計重試按鈕的行為和回饋。如果重試反覆失敗，使用者需要退路而非重試迴圈，<a href="/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口</a>提供逃生設計。Error 狀態在畫面層級的定位和退出路徑回到 <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a>的矩陣框架。</p>
]]></content:encoded></item><item><title>Biometric fallback 完整設計</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/</guid><description>&lt;p>Biometric &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate&lt;/a> 的 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">fallback&lt;/a> 設計需要理解兩件事：平台的認證 API 在不同情境下的行為差異，以及安全收益和可用性代價之間的顯式取捨。&lt;/p>
&lt;h2 id="生物辨識失敗的情境">生物辨識失敗的情境&lt;/h2>
&lt;p>生物辨識失敗有多種原因，每種原因對使用者的影響和合理的 fallback 不同。&lt;/p>
&lt;h3 id="暫時性失敗">暫時性失敗&lt;/h3>
&lt;p>Face ID 因光線不足辨識失敗、指紋因手指潮濕讀取失敗。使用者的生物特徵正常，只是當次辨識條件不佳。重試可能成功。&lt;/p>
&lt;h3 id="持續性失敗">持續性失敗&lt;/h3>
&lt;p>使用者戴口罩讓 Face ID 無法辨識（較舊的 iOS 版本）、手指受傷影響指紋辨識。生物特徵暫時改變，短期內重試都不會成功。需要替代認證方式。&lt;/p>
&lt;h3 id="硬體不可用">硬體不可用&lt;/h3>
&lt;p>裝置沒有 Face ID / Touch ID 模組（較舊機型）、模擬器不支援生物辨識、生物辨識功能被裝置管理策略（MDM）禁用。需要替代認證方式。&lt;/p>
&lt;h3 id="使用者未設定">使用者未設定&lt;/h3>
&lt;p>裝置有硬體但使用者沒有設定 Face ID 或指紋。系統的 &lt;code>canCheckBiometrics&lt;/code> 回傳 &lt;code>true&lt;/code>（硬體存在）但實際認證會失敗。需要引導使用者設定或提供替代認證。&lt;/p>
&lt;h2 id="ios-和-android-的行為差異">iOS 和 Android 的行為差異&lt;/h2>
&lt;h3 id="ioslocalauthentication">iOS（LocalAuthentication）&lt;/h3>
&lt;p>iOS 的 &lt;code>LAContext.evaluatePolicy&lt;/code> 有兩個 policy：&lt;/p>
&lt;ul>
&lt;li>&lt;code>deviceOwnerAuthenticationWithBiometrics&lt;/code>：只接受生物辨識，失敗後不自動提示密碼&lt;/li>
&lt;li>&lt;code>deviceOwnerAuthentication&lt;/code>：先嘗試生物辨識，失敗後系統自動彈出裝置密碼輸入&lt;/li>
&lt;/ul>
&lt;p>Flutter 的 &lt;code>local_auth&lt;/code> 套件的 &lt;code>biometricOnly&lt;/code> 參數對應這兩個 policy。&lt;code>biometricOnly: true&lt;/code> 用前者，&lt;code>biometricOnly: false&lt;/code> 用後者。&lt;/p>
&lt;p>iOS 的行為特點：系統控制認證 UI（不是 app 自行繪製），認證失敗次數過多會自動鎖定（需要輸入密碼解鎖），Face ID 多次失敗後系統會自動提供密碼選項（即使 app 要求 biometricOnly）。&lt;/p>
&lt;h3 id="androidbiometricprompt">Android（BiometricPrompt）&lt;/h3>
&lt;p>Android 的 BiometricPrompt 分成三個 class：&lt;/p>
&lt;ul>
&lt;li>&lt;code>BIOMETRIC_STRONG&lt;/code>：只接受 Class 3 生物辨識（經過硬體安全模組驗證的指紋/面部）&lt;/li>
&lt;li>&lt;code>BIOMETRIC_WEAK&lt;/code>：接受 Class 2 和 Class 3 生物辨識&lt;/li>
&lt;li>&lt;code>DEVICE_CREDENTIAL&lt;/code>：接受裝置 PIN/圖形/密碼&lt;/li>
&lt;/ul>
&lt;p>三個 class 可以用 &lt;code>|&lt;/code> 組合。&lt;code>BIOMETRIC_STRONG | DEVICE_CREDENTIAL&lt;/code> 表示先嘗試強生物辨識，失敗後 fallback 到裝置密碼。&lt;/p>
&lt;p>Android 的行為特點：不同廠商的生物辨識品質差異大（Samsung 的面部辨識和 Pixel 的面部辨識安全等級不同）、部分裝置的指紋感測器在螢幕下方（使用者可能不知道在哪裡觸碰）。&lt;/p>
&lt;h2 id="安全-vs-可用性的顯式取捨">安全 vs 可用性的顯式取捨&lt;/h2>
&lt;p>&lt;code>biometricOnly&lt;/code> 的決策涉及安全和可用性的取捨。這個取捨應該在功能規格中顯式記錄，讓後續的 code review 和維護者能理解決策的背景。&lt;/p>
&lt;p>記錄格式建議：&lt;/p>





&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">Gate: biometric authentication
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">Decision: biometricOnly = false (allow device credential fallback)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">Security trade-off: device credential (PIN/password) is weaker than biometric
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">Rationale: self-hosted tool, user = owner, availability &amp;gt; auth strength
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">Risk accepted: someone with device PIN can access the app&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>app_tunnel 選擇 &lt;code>biometricOnly: true&lt;/code> 的原始意圖是「安全性更高」，但沒有顯式記錄取捨，也沒有評估「Face ID 不可用時使用者完全無法使用 app」的代價。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於完全無法使用的可用性風險（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Gate 設計的通用方法論 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>開發環境遮蔽 gate 問題 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表&lt;/a>&lt;/li>
&lt;li>安全 vs 可用性在 monitoring 中的對應 → &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Biometric <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate</a> 的 <a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">fallback</a> 設計需要理解兩件事：平台的認證 API 在不同情境下的行為差異，以及安全收益和可用性代價之間的顯式取捨。</p>
<h2 id="生物辨識失敗的情境">生物辨識失敗的情境</h2>
<p>生物辨識失敗有多種原因，每種原因對使用者的影響和合理的 fallback 不同。</p>
<h3 id="暫時性失敗">暫時性失敗</h3>
<p>Face ID 因光線不足辨識失敗、指紋因手指潮濕讀取失敗。使用者的生物特徵正常，只是當次辨識條件不佳。重試可能成功。</p>
<h3 id="持續性失敗">持續性失敗</h3>
<p>使用者戴口罩讓 Face ID 無法辨識（較舊的 iOS 版本）、手指受傷影響指紋辨識。生物特徵暫時改變，短期內重試都不會成功。需要替代認證方式。</p>
<h3 id="硬體不可用">硬體不可用</h3>
<p>裝置沒有 Face ID / Touch ID 模組（較舊機型）、模擬器不支援生物辨識、生物辨識功能被裝置管理策略（MDM）禁用。需要替代認證方式。</p>
<h3 id="使用者未設定">使用者未設定</h3>
<p>裝置有硬體但使用者沒有設定 Face ID 或指紋。系統的 <code>canCheckBiometrics</code> 回傳 <code>true</code>（硬體存在）但實際認證會失敗。需要引導使用者設定或提供替代認證。</p>
<h2 id="ios-和-android-的行為差異">iOS 和 Android 的行為差異</h2>
<h3 id="ioslocalauthentication">iOS（LocalAuthentication）</h3>
<p>iOS 的 <code>LAContext.evaluatePolicy</code> 有兩個 policy：</p>
<ul>
<li><code>deviceOwnerAuthenticationWithBiometrics</code>：只接受生物辨識，失敗後不自動提示密碼</li>
<li><code>deviceOwnerAuthentication</code>：先嘗試生物辨識，失敗後系統自動彈出裝置密碼輸入</li>
</ul>
<p>Flutter 的 <code>local_auth</code> 套件的 <code>biometricOnly</code> 參數對應這兩個 policy。<code>biometricOnly: true</code> 用前者，<code>biometricOnly: false</code> 用後者。</p>
<p>iOS 的行為特點：系統控制認證 UI（不是 app 自行繪製），認證失敗次數過多會自動鎖定（需要輸入密碼解鎖），Face ID 多次失敗後系統會自動提供密碼選項（即使 app 要求 biometricOnly）。</p>
<h3 id="androidbiometricprompt">Android（BiometricPrompt）</h3>
<p>Android 的 BiometricPrompt 分成三個 class：</p>
<ul>
<li><code>BIOMETRIC_STRONG</code>：只接受 Class 3 生物辨識（經過硬體安全模組驗證的指紋/面部）</li>
<li><code>BIOMETRIC_WEAK</code>：接受 Class 2 和 Class 3 生物辨識</li>
<li><code>DEVICE_CREDENTIAL</code>：接受裝置 PIN/圖形/密碼</li>
</ul>
<p>三個 class 可以用 <code>|</code> 組合。<code>BIOMETRIC_STRONG | DEVICE_CREDENTIAL</code> 表示先嘗試強生物辨識，失敗後 fallback 到裝置密碼。</p>
<p>Android 的行為特點：不同廠商的生物辨識品質差異大（Samsung 的面部辨識和 Pixel 的面部辨識安全等級不同）、部分裝置的指紋感測器在螢幕下方（使用者可能不知道在哪裡觸碰）。</p>
<h2 id="安全-vs-可用性的顯式取捨">安全 vs 可用性的顯式取捨</h2>
<p><code>biometricOnly</code> 的決策涉及安全和可用性的取捨。這個取捨應該在功能規格中顯式記錄，讓後續的 code review 和維護者能理解決策的背景。</p>
<p>記錄格式建議：</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">Gate: biometric authentication
</span></span><span class="line"><span class="ln">2</span><span class="cl">Decision: biometricOnly = false (allow device credential fallback)
</span></span><span class="line"><span class="ln">3</span><span class="cl">Security trade-off: device credential (PIN/password) is weaker than biometric
</span></span><span class="line"><span class="ln">4</span><span class="cl">Rationale: self-hosted tool, user = owner, availability &gt; auth strength
</span></span><span class="line"><span class="ln">5</span><span class="cl">Risk accepted: someone with device PIN can access the app</span></span></code></pre></div><p>app_tunnel 選擇 <code>biometricOnly: true</code> 的原始意圖是「安全性更高」，但沒有顯式記錄取捨，也沒有評估「Face ID 不可用時使用者完全無法使用 app」的代價。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於完全無法使用的可用性風險（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Gate 設計的通用方法論 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>開發環境遮蔽 gate 問題 → <a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a></li>
<li>安全 vs 可用性在 monitoring 中的對應 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a></li>
</ul>
]]></content:encoded></item><item><title>Flutter GoRouter 導航設計</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/</guid><description>&lt;p>GoRouter 是 Flutter 官方推薦的 declarative router。路由定義集中在一個 &lt;code>GoRouter&lt;/code> 物件中，導航操作用 URL path 表達（&lt;code>context.go('/terminal')&lt;/code>），支援 deep link、redirect、和巢狀路由。&lt;/p>
&lt;h2 id="路由定義">路由定義&lt;/h2>
&lt;p>GoRouter 的路由定義是一棵樹，每個節點是一個 &lt;code>GoRoute&lt;/code>，指定 path 和 builder。&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">GoRouter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">routes:&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">HomeScreen&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/enrollment&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">EnrollmentScreen&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/terminal&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">TerminalScreen&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="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>路由定義是 app 所有可到達畫面的完整清單。新增畫面時先在路由定義中加入 path，再實作 builder。路由定義同時也是路由可達性檢查的 source of truth（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性&lt;/a>）。&lt;/p>
&lt;h2 id="導航-api">導航 API&lt;/h2>
&lt;p>GoRouter 提供三個主要的導航方法，語意不同，適用場景不同。&lt;/p>
&lt;h3 id="contextgopath">context.go(path)&lt;/h3>
&lt;p>替換整個導航堆疊。&lt;code>go('/terminal')&lt;/code> 讓使用者直接到 terminal 畫面，按 back 不會回到前一個畫面（堆疊已被替換）。&lt;/p>
&lt;p>適合場景：切換主要工作區。從登入畫面到首頁（登入成功後使用者不應該按 back 回到登入畫面）。&lt;/p>
&lt;h3 id="contextpushpath">context.push(path)&lt;/h3>
&lt;p>把新畫面推入導航堆疊。&lt;code>push('/enrollment')&lt;/code> 讓使用者到 enrollment 畫面，按 back 回到前一個畫面。&lt;/p>
&lt;p>適合場景：暫時離開做一件事，做完回來。從首頁到配對畫面，配對完成後按 back 回首頁。&lt;/p>
&lt;h3 id="contextpushreplacementpath">context.pushReplacement(path)&lt;/h3>
&lt;p>替換堆疊頂端的畫面。不改變堆疊深度 — 前一個畫面被新畫面取代，按 back 回到更早的畫面。&lt;/p>
&lt;p>適合場景：步驟式流程中的前進。步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 不會回到步驟 2（已被替換），而是回到流程開始前的畫面。&lt;/p>
&lt;h2 id="redirect-機制">Redirect 機制&lt;/h2>
&lt;p>GoRouter 的 redirect 在每次導航前執行，可以根據 app 狀態（登入狀態、權限）把使用者導向不同畫面。&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">GoRouter&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="nl">redirect:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&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">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">final&lt;/span> &lt;span class="n">isLoggedIn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">authState&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">isLoggedIn&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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">!&lt;/span>&lt;span class="n">isLoggedIn&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">matchedLocation&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s1">&amp;#39;/login&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;/login&amp;#39;&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="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">isLoggedIn&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">matchedLocation&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s1">&amp;#39;/login&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;/&amp;#39;&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="k">return&lt;/span> &lt;span class="kc">null&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 不 redirect
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&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="nl">routes:&lt;/span> &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="p">);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Redirect 集中管理「什麼條件下使用者不能到某個畫面」的邏輯。比在每個畫面的 &lt;code>initState&lt;/code> 中各自檢查更容易維護和測試。&lt;/p>
&lt;h2 id="shellroute巢狀導航">ShellRoute（巢狀導航）&lt;/h2>
&lt;p>ShellRoute 讓多個畫面共享同一個外殼（tab bar、bottom navigation、drawer）。子路由的導航在 shell 內發生，shell 本身不變。&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">ShellRoute&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="nl">builder:&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">context&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">state&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">child&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="n">ScaffoldWithNavBar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">child:&lt;/span> &lt;span class="n">child&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="nl">routes:&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/search&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/profile&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&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;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;/code>&lt;/pre>&lt;/div>&lt;p>ShellRoute 適合 tab bar 導航模式 — 底部的 tab bar 是 shell，每個 tab 的內容是子路由。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>go / push / pushReplacement 的 UX 語意 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push vs pushReplacement 語意表&lt;/a>&lt;/li>
&lt;li>iOS 和 Android 的導航差異 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/" data-link-title="iOS HIG vs Material Design 導航差異" data-link-desc="兩個平台在 back 行為、手勢、tab bar 位置、modal 呈現上的差異 — 跨平台 app 需要決定遵循哪套慣例">iOS HIG vs Material Design 導航差異&lt;/a>&lt;/li>
&lt;li>Deep link 設計 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/deep-link-design/" data-link-title="Deep link 設計" data-link-desc="URL scheme / Universal Link / App Link — deep link 讓外部來源直接導航到 app 的特定畫面">Deep link 設計&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>GoRouter 是 Flutter 官方推薦的 declarative router。路由定義集中在一個 <code>GoRouter</code> 物件中，導航操作用 URL path 表達（<code>context.go('/terminal')</code>），支援 deep link、redirect、和巢狀路由。</p>
<h2 id="路由定義">路由定義</h2>
<p>GoRouter 的路由定義是一棵樹，每個節點是一個 <code>GoRoute</code>，指定 path 和 builder。</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">GoRouter</span><span class="p">(</span><span class="nl">routes:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">HomeScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/enrollment&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">EnrollmentScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/terminal&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">TerminalScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>路由定義是 app 所有可到達畫面的完整清單。新增畫面時先在路由定義中加入 path，再實作 builder。路由定義同時也是路由可達性檢查的 source of truth（<a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性</a>）。</p>
<h2 id="導航-api">導航 API</h2>
<p>GoRouter 提供三個主要的導航方法，語意不同，適用場景不同。</p>
<h3 id="contextgopath">context.go(path)</h3>
<p>替換整個導航堆疊。<code>go('/terminal')</code> 讓使用者直接到 terminal 畫面，按 back 不會回到前一個畫面（堆疊已被替換）。</p>
<p>適合場景：切換主要工作區。從登入畫面到首頁（登入成功後使用者不應該按 back 回到登入畫面）。</p>
<h3 id="contextpushpath">context.push(path)</h3>
<p>把新畫面推入導航堆疊。<code>push('/enrollment')</code> 讓使用者到 enrollment 畫面，按 back 回到前一個畫面。</p>
<p>適合場景：暫時離開做一件事，做完回來。從首頁到配對畫面，配對完成後按 back 回首頁。</p>
<h3 id="contextpushreplacementpath">context.pushReplacement(path)</h3>
<p>替換堆疊頂端的畫面。不改變堆疊深度 — 前一個畫面被新畫面取代，按 back 回到更早的畫面。</p>
<p>適合場景：步驟式流程中的前進。步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 不會回到步驟 2（已被替換），而是回到流程開始前的畫面。</p>
<h2 id="redirect-機制">Redirect 機制</h2>
<p>GoRouter 的 redirect 在每次導航前執行，可以根據 app 狀態（登入狀態、權限）把使用者導向不同畫面。</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">GoRouter</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">redirect:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">final</span> <span class="n">isLoggedIn</span> <span class="o">=</span> <span class="n">authState</span><span class="p">.</span><span class="n">isLoggedIn</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="o">!</span><span class="n">isLoggedIn</span> <span class="o">&amp;&amp;</span> <span class="n">state</span><span class="p">.</span><span class="n">matchedLocation</span> <span class="o">!=</span> <span class="s1">&#39;/login&#39;</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;/login&#39;</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="n">isLoggedIn</span> <span class="o">&amp;&amp;</span> <span class="n">state</span><span class="p">.</span><span class="n">matchedLocation</span> <span class="o">==</span> <span class="s1">&#39;/login&#39;</span><span class="p">)</span> <span class="k">return</span> <span class="s1">&#39;/&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="k">return</span> <span class="kc">null</span><span class="p">;</span> <span class="c1">// 不 redirect
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="p">},</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="nl">routes:</span> <span class="p">[...],</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">);</span></span></span></code></pre></div><p>Redirect 集中管理「什麼條件下使用者不能到某個畫面」的邏輯。比在每個畫面的 <code>initState</code> 中各自檢查更容易維護和測試。</p>
<h2 id="shellroute巢狀導航">ShellRoute（巢狀導航）</h2>
<p>ShellRoute 讓多個畫面共享同一個外殼（tab bar、bottom navigation、drawer）。子路由的導航在 shell 內發生，shell 本身不變。</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">ShellRoute</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">builder:</span> <span class="p">(</span><span class="n">context</span><span class="p">,</span> <span class="n">state</span><span class="p">,</span> <span class="n">child</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="n">ScaffoldWithNavBar</span><span class="p">(</span><span class="nl">child:</span> <span class="n">child</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nl">routes:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/home&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/search&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/profile&#39;</span><span class="p">,</span> <span class="nl">builder:</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></code></pre></div><p>ShellRoute 適合 tab bar 導航模式 — 底部的 tab bar 是 shell，每個 tab 的內容是子路由。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>go / push / pushReplacement 的 UX 語意 → <a href="/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push vs pushReplacement 語意表</a></li>
<li>iOS 和 Android 的導航差異 → <a href="/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/" data-link-title="iOS HIG vs Material Design 導航差異" data-link-desc="兩個平台在 back 行為、手勢、tab bar 位置、modal 呈現上的差異 — 跨平台 app 需要決定遵循哪套慣例">iOS HIG vs Material Design 導航差異</a></li>
<li>Deep link 設計 → <a href="/blog/ux-design/05-navigation-patterns/deep-link-design/" data-link-title="Deep link 設計" data-link-desc="URL scheme / Universal Link / App Link — deep link 讓外部來源直接導航到 app 的特定畫面">Deep link 設計</a></li>
</ul>
]]></content:encoded></item><item><title>Gate（UX）</title><link>https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/</guid><description>&lt;p>Gate 的核心概念是「使用者操作流程中必須通過才能繼續的關卡」。認證、網路連線、權限請求、環境檢查、付費牆都是 gate。每個 gate 需要設計三條路徑：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼。可先對照 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">Fallback（UX）&lt;/a> 和 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">Fallback（Backend）&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>UX 語境的 gate 聚焦在使用者體驗層 — 關注的是「使用者被擋住時看到什麼、能做什麼」。和 backend 語境的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">gate decision&lt;/a> 不同，後者關注的是部署流程中的品質關卡。Gate 的失敗路徑和不確定路徑應該反映在&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>的退出路徑欄中。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要 gate 設計的訊號是使用者在某個功能前被阻擋且沒有替代路徑。常見情境：biometric 認證失敗後使用者無法進入 app、網路斷線後使用者被困在 loading 畫面、權限被拒後功能靜默消失但使用者不知道為什麼。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>Gate 的設計責任是確保每條路徑都有明確的使用者體驗。成功路徑通常最先被設計；失敗路徑需要提供 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">UX fallback&lt;/a>（替代驗證、降級功能、返回上一頁）；不確定路徑需要 loading 指示和取消操作。開發環境可能遮蔽 gate 問題 — 模擬器跳過認證、debug build 自動授權 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證。&lt;/p></description><content:encoded><![CDATA[<p>Gate 的核心概念是「使用者操作流程中必須通過才能繼續的關卡」。認證、網路連線、權限請求、環境檢查、付費牆都是 gate。每個 gate 需要設計三條路徑：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼。可先對照 <a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">Fallback（UX）</a> 和 <a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">Fallback（Backend）</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>UX 語境的 gate 聚焦在使用者體驗層 — 關注的是「使用者被擋住時看到什麼、能做什麼」。和 backend 語境的 <a href="/blog/backend/knowledge-cards/gate-decision/" data-link-title="Gate Decision" data-link-desc="說明 release gate 如何把證據轉成放行、暫停、回退或補證據的決策">gate decision</a> 不同，後者關注的是部署流程中的品質關卡。Gate 的失敗路徑和不確定路徑應該反映在<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>的退出路徑欄中。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要 gate 設計的訊號是使用者在某個功能前被阻擋且沒有替代路徑。常見情境：biometric 認證失敗後使用者無法進入 app、網路斷線後使用者被困在 loading 畫面、權限被拒後功能靜默消失但使用者不知道為什麼。</p>
<h2 id="設計責任">設計責任</h2>
<p>Gate 的設計責任是確保每條路徑都有明確的使用者體驗。成功路徑通常最先被設計；失敗路徑需要提供 <a href="/blog/ux-design/knowledge-cards/ux-fallback/" data-link-title="Fallback（UX）" data-link-desc="說明 gate 未通過時使用者的替代路徑，和 backend fallback（server-side 降級）的語意區別">UX fallback</a>（替代驗證、降級功能、返回上一頁）；不確定路徑需要 loading 指示和取消操作。開發環境可能遮蔽 gate 問題 — 模擬器跳過認證、debug build 自動授權 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證。</p>
]]></content:encoded></item><item><title>Retry 機制 UX</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/retry-mechanism-ux/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/retry-mechanism-ux/</guid><description>&lt;p>重試是錯誤恢復的第一手段。重試策略的選擇取決於兩個因素：失敗是否可能自行恢復（暫時性網路中斷 vs 伺服器不存在），以及使用者是否願意等待（前景操作 vs 背景同步）。&lt;/p>
&lt;h2 id="自動重試-vs-手動重試">自動重試 vs 手動重試&lt;/h2>
&lt;h3 id="自動重試">自動重試&lt;/h3>
&lt;p>系統在失敗後自動重新嘗試，使用者不需要手動操作。適合背景操作（資料同步、事件上報、心跳檢查）和暫時性失敗（網路閃斷、server 短暫過載）。&lt;/p>
&lt;p>自動重試的 UX 要求：使用者需要知道系統正在重試。「連線中斷，正在重新連線（第 2 次嘗試）」比靜默重試更透明。如果使用者不知道系統在重試，靜默的等待會被解讀為「系統卡住了」。&lt;/p>
&lt;p>自動重試必須有上限。無限重試在不可恢復的失敗場景中（伺服器已關閉、認證已過期）浪費資源和電量，且使用者無法察覺問題。&lt;/p>
&lt;h3 id="手動重試">手動重試&lt;/h3>
&lt;p>使用者點擊「重試」按鈕觸發重新嘗試。適合前景操作（使用者主動發起的連線、提交、搜尋）和需要使用者確認意圖的場景。&lt;/p>
&lt;p>手動重試的 UX 要求：重試按鈕在 error 畫面上明顯可見，旁邊有退出路徑（返回按鈕）。使用者可以選擇重試或放棄。&lt;/p>
&lt;h3 id="混合策略">混合策略&lt;/h3>
&lt;p>先自動重試 N 次，失敗後切換到手動重試。這是連線類操作的常見模式 — WebSocket 斷線後自動重連 3 次，3 次都失敗後顯示「連線失敗」+ 手動重連按鈕。&lt;/p>
&lt;h2 id="重試間隔策略">重試間隔策略&lt;/h2>
&lt;h3 id="立即重試">立即重試&lt;/h3>
&lt;p>失敗後立即重新嘗試，中間沒有等待。適合極短暫的瞬態失敗（DNS 解析偶發失敗、TCP 連線被 reset）。&lt;/p>
&lt;p>立即重試的風險是在 server 過載時加劇問題 — 多個 client 同時立即重試產生 thundering herd 效應。&lt;/p>
&lt;h3 id="固定間隔重試">固定間隔重試&lt;/h3>
&lt;p>每次重試間隔固定時間（例如每 5 秒重試一次）。簡單可預測，使用者能估算等待時間。&lt;/p>
&lt;h3 id="指數退避exponential-backoff">指數退避（exponential backoff）&lt;/h3>
&lt;p>每次重試的間隔加倍。第一次 1 秒、第二次 2 秒、第三次 4 秒、第四次 8 秒。加上隨機抖動（jitter）避免多個 client 同步重試。&lt;/p>
&lt;p>指數退避適合 server 端過載或暫時不可用的場景。間隔越來越長給 server 恢復的時間，同時減少 client 的資源消耗。&lt;/p>
&lt;p>指數退避的 UX 挑戰是使用者感知到的等待越來越長。第四次重試等 8 秒時使用者可能已經失去耐心。解法是顯示倒數計時（「12 秒後自動重試」）和手動重試按鈕（使用者可以跳過等待立即重試）。&lt;/p>
&lt;h2 id="重試狀態的-ui-呈現">重試狀態的 UI 呈現&lt;/h2>
&lt;p>使用者需要知道三件事：系統正在重試、已經重試了幾次、下一次重試在什麼時候。&lt;/p>





&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">連線失敗，正在重新連線...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">第 2 次嘗試（共 5 次上限）
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">下次重試：8 秒後 [立即重試] [返回首頁]&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>重試達到上限後，UI 從「重試中」切換到「失敗」狀態，顯示手動重試和退出路徑。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>部分功能不可用的降級設計 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/degraded-mode-design/" data-link-title="Degraded mode 設計" data-link-desc="部分功能不可用時怎麼告知使用者 — 靜默隱藏 vs 明確標示 vs 替代方案的設計取捨">Degraded mode 設計&lt;/a>&lt;/li>
&lt;li>重試循環的逃生口 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口&lt;/a>&lt;/li>
&lt;li>Gate 失敗的 fallback → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/" data-link-title="模組二：Gate 與 Fallback 設計" data-link-desc="Biometric / Network / Auth / Permission — 每個 gate 成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">ux-design 模組二 Gate 與 Fallback&lt;/a>&lt;/li>
&lt;li>Server 端的限速機制（影響 retry 策略設計）→ &lt;a href="https://tarrragon.github.io/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>重試是錯誤恢復的第一手段。重試策略的選擇取決於兩個因素：失敗是否可能自行恢復（暫時性網路中斷 vs 伺服器不存在），以及使用者是否願意等待（前景操作 vs 背景同步）。</p>
<h2 id="自動重試-vs-手動重試">自動重試 vs 手動重試</h2>
<h3 id="自動重試">自動重試</h3>
<p>系統在失敗後自動重新嘗試，使用者不需要手動操作。適合背景操作（資料同步、事件上報、心跳檢查）和暫時性失敗（網路閃斷、server 短暫過載）。</p>
<p>自動重試的 UX 要求：使用者需要知道系統正在重試。「連線中斷，正在重新連線（第 2 次嘗試）」比靜默重試更透明。如果使用者不知道系統在重試，靜默的等待會被解讀為「系統卡住了」。</p>
<p>自動重試必須有上限。無限重試在不可恢復的失敗場景中（伺服器已關閉、認證已過期）浪費資源和電量，且使用者無法察覺問題。</p>
<h3 id="手動重試">手動重試</h3>
<p>使用者點擊「重試」按鈕觸發重新嘗試。適合前景操作（使用者主動發起的連線、提交、搜尋）和需要使用者確認意圖的場景。</p>
<p>手動重試的 UX 要求：重試按鈕在 error 畫面上明顯可見，旁邊有退出路徑（返回按鈕）。使用者可以選擇重試或放棄。</p>
<h3 id="混合策略">混合策略</h3>
<p>先自動重試 N 次，失敗後切換到手動重試。這是連線類操作的常見模式 — WebSocket 斷線後自動重連 3 次，3 次都失敗後顯示「連線失敗」+ 手動重連按鈕。</p>
<h2 id="重試間隔策略">重試間隔策略</h2>
<h3 id="立即重試">立即重試</h3>
<p>失敗後立即重新嘗試，中間沒有等待。適合極短暫的瞬態失敗（DNS 解析偶發失敗、TCP 連線被 reset）。</p>
<p>立即重試的風險是在 server 過載時加劇問題 — 多個 client 同時立即重試產生 thundering herd 效應。</p>
<h3 id="固定間隔重試">固定間隔重試</h3>
<p>每次重試間隔固定時間（例如每 5 秒重試一次）。簡單可預測，使用者能估算等待時間。</p>
<h3 id="指數退避exponential-backoff">指數退避（exponential backoff）</h3>
<p>每次重試的間隔加倍。第一次 1 秒、第二次 2 秒、第三次 4 秒、第四次 8 秒。加上隨機抖動（jitter）避免多個 client 同步重試。</p>
<p>指數退避適合 server 端過載或暫時不可用的場景。間隔越來越長給 server 恢復的時間，同時減少 client 的資源消耗。</p>
<p>指數退避的 UX 挑戰是使用者感知到的等待越來越長。第四次重試等 8 秒時使用者可能已經失去耐心。解法是顯示倒數計時（「12 秒後自動重試」）和手動重試按鈕（使用者可以跳過等待立即重試）。</p>
<h2 id="重試狀態的-ui-呈現">重試狀態的 UI 呈現</h2>
<p>使用者需要知道三件事：系統正在重試、已經重試了幾次、下一次重試在什麼時候。</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">連線失敗，正在重新連線...
</span></span><span class="line"><span class="ln">2</span><span class="cl">第 2 次嘗試（共 5 次上限）
</span></span><span class="line"><span class="ln">3</span><span class="cl">下次重試：8 秒後 [立即重試] [返回首頁]</span></span></code></pre></div><p>重試達到上限後，UI 從「重試中」切換到「失敗」狀態，顯示手動重試和退出路徑。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>部分功能不可用的降級設計 → <a href="/blog/ux-design/04-error-recovery/degraded-mode-design/" data-link-title="Degraded mode 設計" data-link-desc="部分功能不可用時怎麼告知使用者 — 靜默隱藏 vs 明確標示 vs 替代方案的設計取捨">Degraded mode 設計</a></li>
<li>重試循環的逃生口 → <a href="/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口</a></li>
<li>Gate 失敗的 fallback → <a href="/blog/ux-design/02-gate-fallback/" data-link-title="模組二：Gate 與 Fallback 設計" data-link-desc="Biometric / Network / Auth / Permission — 每個 gate 成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">ux-design 模組二 Gate 與 Fallback</a></li>
<li>Server 端的限速機制（影響 retry 策略設計）→ <a href="/blog/devops/03-traffic-management/" data-link-title="模組三：流量管控" data-link-desc="收到的流量超過處理能力時怎麼辦 — 背壓、rate limit、熔斷、bulkhead 四種防護機制">DevOps 流量管控</a></li>
</ul>
]]></content:encoded></item><item><title>Terminal app 輸入設計</title><link>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/terminal-input-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/terminal-input-design/</guid><description>&lt;p>Terminal app 在手機上的輸入需求和一般文字輸入有根本差異。CLI 指令是結構化語法，路徑分隔符、flag 縮寫、管線符號都有精確語意 — 手機鍵盤為自然語言設計的自動行為（校正、建議、學習）在 CLI 場景中全部變成干擾。&lt;/p>
&lt;h2 id="cli-輸入的特殊性">CLI 輸入的特殊性&lt;/h2>
&lt;p>桌面終端機的鍵盤直接傳送按鍵事件，沒有中間的輸入法處理層。使用者按 &lt;code>l&lt;/code> 就是 &lt;code>l&lt;/code>，按 Tab 就是 Tab，按 Ctrl+C 就是 interrupt signal。&lt;/p>
&lt;p>手機鍵盤在使用者和 app 之間插入了 IME 層。使用者按 &lt;code>l&lt;/code> 時，IME 可能等待後續按鍵組合成完整詞彙再傳送；使用者按的按鍵可能被自動校正替換；使用者的輸入被記錄到 IME 詞庫供跨 app 學習。&lt;/p>
&lt;p>Terminal app 需要繞過或控制 IME 層的這些行為。app_tunnel 的 TextField 用 &lt;code>TextInputType.visiblePassword&lt;/code> + &lt;code>autocorrect: false&lt;/code> + &lt;code>enableSuggestions: false&lt;/code> + &lt;code>enableIMEPersonalizedLearning: false&lt;/code> 四個參數關閉 IME 的自動行為（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a>）。&lt;/p>
&lt;h2 id="整行送出-vs-逐字元protocol-層的影響">整行送出 vs 逐字元：protocol 層的影響&lt;/h2>
&lt;p>整行送出和逐字元送出在 UI 層看起來只是「按 Enter 送出整行」和「每個按鍵即時送出」的差別，但在 protocol 層是兩種不同的通訊模式。&lt;/p>
&lt;h3 id="整行送出">整行送出&lt;/h3>
&lt;p>Client 端累積使用者輸入，使用者按 Enter 時傳送完整指令字串加換行符（&lt;code>ls -la\n&lt;/code>）。Server 端收到完整行後處理。&lt;/p>
&lt;p>Protocol 設計簡單：每個 WebSocket frame 是一個完整指令。Server 不需要管理部分輸入的狀態，也不需要即時回應 Tab 或方向鍵。&lt;/p>
&lt;p>代價：使用者無法在手機上使用 Tab 補全（Tab 被 IME 攔截或不存在）、無法用方向鍵在指令中移動游標（移動的是 TextField 的游標，不是 server 端的 readline 游標）。&lt;/p>
&lt;h3 id="逐字元送出">逐字元送出&lt;/h3>
&lt;p>Client 端每個按鍵即時傳送一個 WebSocket frame。Server 端的 shell 即時處理每個字元，包括 Tab 補全（server 回傳補全結果）、Ctrl+C（server 中斷當前程序）、方向鍵（server 端 readline 移動游標）。&lt;/p>
&lt;p>Protocol 設計複雜：每個按鍵一個 frame，frame 內容是單一字元或控制序列。Server 端必須維護 readline 狀態。Client 端必須正確編碼控制字元（Ctrl+C = 0x03, Tab = 0x09）。&lt;/p>
&lt;p>代價：protocol 複雜度高，每個按鍵都有網路延遲。在高延遲網路上輸入體驗差（打字後要等 round-trip 才看到回顯）。&lt;/p>
&lt;h3 id="決策在-protocol-層做">決策在 protocol 層做&lt;/h3>
&lt;p>app_tunnel 選擇整行送出，犧牲 Tab 補全換取簡單的 protocol 設計。這個決策應該在 protocol spec 階段做 — 因為它影響 server 端（ttyd）的行為預期和 client 端的 frame 格式。在 UI 實作時才臨時決定，可能和 server 端的行為預期不一致。&lt;/p>
&lt;h2 id="特殊按鍵的-ui-方案">特殊按鍵的 UI 方案&lt;/h2>
&lt;p>手機沒有 Esc、Tab、Ctrl、方向鍵。Terminal app 需要額外的 UI 元件提供這些按鍵。&lt;/p>
&lt;h3 id="底部工具列">底部工具列&lt;/h3>
&lt;p>固定在鍵盤上方的一排按鈕，提供常用特殊鍵。app_tunnel 的工具列包含 Esc、Tab、Ctrl、四個方向鍵。&lt;/p>
&lt;p>工具列的設計考量：按鈕大小（手指能精確觸碰的最小尺寸約 44x44 pt）、排列順序（最常用的放中間）、長按行為（長按 Ctrl 是否支援 Ctrl 組合鍵）。&lt;/p>
&lt;h3 id="ctrl-組合鍵">Ctrl 組合鍵&lt;/h3>
&lt;p>Ctrl+C（中斷）、Ctrl+D（EOF）、Ctrl+Z（暫停）在 CLI 操作中頻繁使用。手機上的實作方式通常是：按下 Ctrl 按鈕後進入「Ctrl 模式」，下一個按鍵自動加 Ctrl 前綴。&lt;/p></description><content:encoded><![CDATA[<p>Terminal app 在手機上的輸入需求和一般文字輸入有根本差異。CLI 指令是結構化語法，路徑分隔符、flag 縮寫、管線符號都有精確語意 — 手機鍵盤為自然語言設計的自動行為（校正、建議、學習）在 CLI 場景中全部變成干擾。</p>
<h2 id="cli-輸入的特殊性">CLI 輸入的特殊性</h2>
<p>桌面終端機的鍵盤直接傳送按鍵事件，沒有中間的輸入法處理層。使用者按 <code>l</code> 就是 <code>l</code>，按 Tab 就是 Tab，按 Ctrl+C 就是 interrupt signal。</p>
<p>手機鍵盤在使用者和 app 之間插入了 IME 層。使用者按 <code>l</code> 時，IME 可能等待後續按鍵組合成完整詞彙再傳送；使用者按的按鍵可能被自動校正替換；使用者的輸入被記錄到 IME 詞庫供跨 app 學習。</p>
<p>Terminal app 需要繞過或控制 IME 層的這些行為。app_tunnel 的 TextField 用 <code>TextInputType.visiblePassword</code> + <code>autocorrect: false</code> + <code>enableSuggestions: false</code> + <code>enableIMEPersonalizedLearning: false</code> 四個參數關閉 IME 的自動行為（<a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a>）。</p>
<h2 id="整行送出-vs-逐字元protocol-層的影響">整行送出 vs 逐字元：protocol 層的影響</h2>
<p>整行送出和逐字元送出在 UI 層看起來只是「按 Enter 送出整行」和「每個按鍵即時送出」的差別，但在 protocol 層是兩種不同的通訊模式。</p>
<h3 id="整行送出">整行送出</h3>
<p>Client 端累積使用者輸入，使用者按 Enter 時傳送完整指令字串加換行符（<code>ls -la\n</code>）。Server 端收到完整行後處理。</p>
<p>Protocol 設計簡單：每個 WebSocket frame 是一個完整指令。Server 不需要管理部分輸入的狀態，也不需要即時回應 Tab 或方向鍵。</p>
<p>代價：使用者無法在手機上使用 Tab 補全（Tab 被 IME 攔截或不存在）、無法用方向鍵在指令中移動游標（移動的是 TextField 的游標，不是 server 端的 readline 游標）。</p>
<h3 id="逐字元送出">逐字元送出</h3>
<p>Client 端每個按鍵即時傳送一個 WebSocket frame。Server 端的 shell 即時處理每個字元，包括 Tab 補全（server 回傳補全結果）、Ctrl+C（server 中斷當前程序）、方向鍵（server 端 readline 移動游標）。</p>
<p>Protocol 設計複雜：每個按鍵一個 frame，frame 內容是單一字元或控制序列。Server 端必須維護 readline 狀態。Client 端必須正確編碼控制字元（Ctrl+C = 0x03, Tab = 0x09）。</p>
<p>代價：protocol 複雜度高，每個按鍵都有網路延遲。在高延遲網路上輸入體驗差（打字後要等 round-trip 才看到回顯）。</p>
<h3 id="決策在-protocol-層做">決策在 protocol 層做</h3>
<p>app_tunnel 選擇整行送出，犧牲 Tab 補全換取簡單的 protocol 設計。這個決策應該在 protocol spec 階段做 — 因為它影響 server 端（ttyd）的行為預期和 client 端的 frame 格式。在 UI 實作時才臨時決定，可能和 server 端的行為預期不一致。</p>
<h2 id="特殊按鍵的-ui-方案">特殊按鍵的 UI 方案</h2>
<p>手機沒有 Esc、Tab、Ctrl、方向鍵。Terminal app 需要額外的 UI 元件提供這些按鍵。</p>
<h3 id="底部工具列">底部工具列</h3>
<p>固定在鍵盤上方的一排按鈕，提供常用特殊鍵。app_tunnel 的工具列包含 Esc、Tab、Ctrl、四個方向鍵。</p>
<p>工具列的設計考量：按鈕大小（手指能精確觸碰的最小尺寸約 44x44 pt）、排列順序（最常用的放中間）、長按行為（長按 Ctrl 是否支援 Ctrl 組合鍵）。</p>
<h3 id="ctrl-組合鍵">Ctrl 組合鍵</h3>
<p>Ctrl+C（中斷）、Ctrl+D（EOF）、Ctrl+Z（暫停）在 CLI 操作中頻繁使用。手機上的實作方式通常是：按下 Ctrl 按鈕後進入「Ctrl 模式」，下一個按鍵自動加 Ctrl 前綴。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四維度決策表 → <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></li>
<li>安全敏感輸入框的 IME 控制 → <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">IME 安全 checklist</a></li>
<li>表單場景的輸入設計 → <a href="/blog/ux-design/03-input-mechanism/form-ux-pattern/" data-link-title="表單 UX 模式" data-link-desc="表單輸入的驗證時機、auto-fill 支援、錯誤回饋設計 — 和 terminal 輸入的決策維度相同但選項不同">表單 UX 模式</a></li>
</ul>
]]></content:encoded></item><item><title>U.C2 biometricOnly=true 無密碼 fallback</title><link>https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/</guid><description>&lt;p>這個案例的核心責任是說明 Gate（使用者必須通過的關卡）的設計不只是「成功時怎麼做」，還必須包含「失敗時的替代路徑」。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 使用 &lt;code>local_auth&lt;/code> 套件進行生物辨識認證。&lt;code>AuthenticationOptions&lt;/code> 設定 &lt;code>biometricOnly: true&lt;/code>，表示只接受生物辨識（Face ID / 指紋），不接受裝置密碼作為 fallback。&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="c1">// 修復前
&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 class="nl">options:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">AuthenticationOptions&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="nl">stickyAuth:&lt;/span> &lt;span class="kc">true&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="nl">biometricOnly:&lt;/span> &lt;span class="kc">true&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Face ID 不可用 → 認證直接失敗
&lt;/span>&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 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>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&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="nl">options:&lt;/span> &lt;span class="kd">const&lt;/span> &lt;span class="n">AuthenticationOptions&lt;/span>&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="nl">stickyAuth:&lt;/span> &lt;span class="kc">true&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="nl">biometricOnly:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Face ID 不可用 → 系統自動提示輸入裝置密碼
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&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;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>Face ID 不可用時（戴口罩、光線差、指紋模糊、模擬器）完全無法使用 app&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復成本&lt;/td>
 &lt;td>改一個 boolean&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>根因&lt;/td>
 &lt;td>企劃階段未設計 biometric gate 的 fallback&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Gate fallback 是設計問題，不是實作問題&lt;/strong>。&lt;code>biometricOnly&lt;/code> 的預設值是 &lt;code>false&lt;/code>（允許密碼 fallback），開發時特意改成 &lt;code>true&lt;/code> 是因為認為「安全性更高」。但這個判斷沒有考慮 fallback 缺失時的 UX 代價 — 使用者完全無法進入 app。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>開發環境遮蔽了問題&lt;/strong>。iOS 模擬器預設不支援 Face ID，但 &lt;code>isAvailable()&lt;/code> 的實作會檢查 &lt;code>isDeviceSupported()&lt;/code> + &lt;code>getAvailableBiometrics().isNotEmpty&lt;/code>。模擬器回傳 &lt;code>isDeviceSupported() = true&lt;/code> 但 &lt;code>getAvailableBiometrics() = []&lt;/code>，所以在模擬器上 &lt;code>isAvailable()&lt;/code> 回傳 false，直接跳過認證走預設路徑。真實裝置上 &lt;code>isAvailable() = true&lt;/code> 但 Face ID 可能失敗，這時沒有 fallback。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>安全性 vs 可用性的取捨需要顯式記錄&lt;/strong>。&lt;code>biometricOnly: true&lt;/code> 的安全收益是「確保只有生物特徵擁有者能操作」；代價是「任何生物辨識失敗場景都阻擋使用」。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於「完全無法使用」的可用性風險。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>每個 gate 設計時列三問&lt;/strong>：成功時做什麼？失敗時做什麼？使用者不知道發生什麼時做什麼？&lt;/li>
&lt;li>&lt;strong>在狀態矩陣標注 gate fallback&lt;/strong>：biometric / network / auth 每個 gate 旁邊標注替代路徑，空白 = 使用者被擋住。&lt;/li>
&lt;li>&lt;strong>安全 vs 可用性取捨顯式記錄&lt;/strong>：在 spec 文件記錄「&lt;code>biometricOnly: false&lt;/code> — 接受密碼 fallback，因為自用工具可用性優先於生物辨識強制」。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計 Gate fallback 體系 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>想了解 biometric 在不同平台的行為差異 → 待補：iOS/Android biometric API 行為對照&lt;/li>
&lt;li>類似案例（導航死胡同）→ &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五個狀態零個退出&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 Gate（使用者必須通過的關卡）的設計不只是「成功時怎麼做」，還必須包含「失敗時的替代路徑」。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 使用 <code>local_auth</code> 套件進行生物辨識認證。<code>AuthenticationOptions</code> 設定 <code>biometricOnly: true</code>，表示只接受生物辨識（Face ID / 指紋），不接受裝置密碼作為 fallback。</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="c1">// 修復前
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="nl">options:</span> <span class="kd">const</span> <span class="n">AuthenticationOptions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="nl">stickyAuth:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="nl">biometricOnly:</span> <span class="kc">true</span><span class="p">,</span>  <span class="c1">// Face ID 不可用 → 認證直接失敗
</span></span></span><span class="line"><span class="ln"> 5</span><span class="cl"><span class="c1"></span><span class="p">),</span>
</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"><span class="c1">// 修復後
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span><span class="nl">options:</span> <span class="kd">const</span> <span class="n">AuthenticationOptions</span><span class="p">(</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="nl">stickyAuth:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="nl">biometricOnly:</span> <span class="kc">false</span><span class="p">,</span> <span class="c1">// Face ID 不可用 → 系統自動提示輸入裝置密碼
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="p">),</span></span></span></code></pre></div><table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>Face ID 不可用時（戴口罩、光線差、指紋模糊、模擬器）完全無法使用 app</td>
      </tr>
      <tr>
          <td>修復成本</td>
          <td>改一個 boolean</td>
      </tr>
      <tr>
          <td>根因</td>
          <td>企劃階段未設計 biometric gate 的 fallback</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Gate fallback 是設計問題，不是實作問題</strong>。<code>biometricOnly</code> 的預設值是 <code>false</code>（允許密碼 fallback），開發時特意改成 <code>true</code> 是因為認為「安全性更高」。但這個判斷沒有考慮 fallback 缺失時的 UX 代價 — 使用者完全無法進入 app。</p>
</li>
<li>
<p><strong>開發環境遮蔽了問題</strong>。iOS 模擬器預設不支援 Face ID，但 <code>isAvailable()</code> 的實作會檢查 <code>isDeviceSupported()</code> + <code>getAvailableBiometrics().isNotEmpty</code>。模擬器回傳 <code>isDeviceSupported() = true</code> 但 <code>getAvailableBiometrics() = []</code>，所以在模擬器上 <code>isAvailable()</code> 回傳 false，直接跳過認證走預設路徑。真實裝置上 <code>isAvailable() = true</code> 但 Face ID 可能失敗，這時沒有 fallback。</p>
</li>
<li>
<p><strong>安全性 vs 可用性的取捨需要顯式記錄</strong>。<code>biometricOnly: true</code> 的安全收益是「確保只有生物特徵擁有者能操作」；代價是「任何生物辨識失敗場景都阻擋使用」。自用工具的使用者就是 owner，密碼 fallback 的安全風險遠低於「完全無法使用」的可用性風險。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>每個 gate 設計時列三問</strong>：成功時做什麼？失敗時做什麼？使用者不知道發生什麼時做什麼？</li>
<li><strong>在狀態矩陣標注 gate fallback</strong>：biometric / network / auth 每個 gate 旁邊標注替代路徑，空白 = 使用者被擋住。</li>
<li><strong>安全 vs 可用性取捨顯式記錄</strong>：在 spec 文件記錄「<code>biometricOnly: false</code> — 接受密碼 fallback，因為自用工具可用性優先於生物辨識強制」。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計 Gate fallback 體系 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>想了解 biometric 在不同平台的行為差異 → 待補：iOS/Android biometric API 行為對照</li>
<li>類似案例（導航死胡同）→ <a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五個狀態零個退出</a></li>
</ul>
]]></content:encoded></item><item><title>從 BDD 操作盤點展開到狀態矩陣</title><link>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/bdd-to-state-matrix/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/bdd-to-state-matrix/</guid><description>&lt;p>BDD 操作盤點描述使用者操作的情境和預期結果，但操作盤點的格式（Given / When / Then）聚焦在「什麼情境下做什麼得到什麼」，容易漏掉畫面層級的兩個面向：每個狀態下使用者能執行哪些操作，以及如何離開當前狀態。&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>補上這兩個面向，讓導航缺口在實作前浮現。&lt;/p>
&lt;h2 id="操作盤點的覆蓋範圍">操作盤點的覆蓋範圍&lt;/h2>
&lt;p>BDD 操作盤點通常包含：&lt;/p>
&lt;ul>
&lt;li>使用者操作（When）：「使用者點擊連線按鈕」&lt;/li>
&lt;li>前端引導（Then）：「顯示連線進度指示」&lt;/li>
&lt;li>後端回應：「WebSocket 連線建立」&lt;/li>
&lt;/ul>
&lt;p>「前端引導」描述的是畫面的顯示內容 — 對應狀態矩陣的「顯示」欄。但操作盤點通常不會展開：連線中的畫面除了顯示進度指示，使用者能做什麼？如果連線失敗，使用者怎麼離開失敗畫面？&lt;/p>
&lt;p>app_tunnel 的操作盤點在「前端引導」欄寫了「連線失敗顯示無法連線」，覆蓋了 error 狀態的顯示。但是「顯示無法連線之後使用者能做什麼」和「使用者怎麼離開這個畫面」都沒有描述。實作出來的 error 狀態有重連按鈕但沒有 back 按鈕 — 重連失敗時使用者被困在 error → retry → error 循環裡（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>）。&lt;/p>
&lt;h2 id="展開步驟">展開步驟&lt;/h2>
&lt;h3 id="步驟一從操作盤點抽出所有畫面">步驟一：從操作盤點抽出所有畫面&lt;/h3>
&lt;p>每個 BDD 情境至少涉及一個畫面。列出所有情境提到的畫面名稱，去重後得到畫面清單。&lt;/p>
&lt;p>例：app_tunnel 的操作盤點涉及三個畫面 — 首頁、配對畫面、終端機畫面。&lt;/p>
&lt;h3 id="步驟二每個畫面列出狀態">步驟二：每個畫面列出狀態&lt;/h3>
&lt;p>從操作盤點的 Given / When / Then 條件中抽出狀態。「Given 連線已建立」暗示有 connected 狀態；「Then 顯示無法連線」暗示有 error 狀態。&lt;/p>
&lt;p>同時檢查程式碼中的狀態 enum — 操作盤點可能遺漏了某些狀態（如 idle、disconnected），程式碼裡有但操作盤點沒提到的狀態同樣需要設計 UI。&lt;/p>
&lt;h3 id="步驟三每個狀態填顯示欄">步驟三：每個狀態填「顯示」欄&lt;/h3>
&lt;p>從操作盤點的「前端引導」直接填入。這一步通常不缺資料，因為操作盤點的強項就是描述顯示內容。&lt;/p>
&lt;h3 id="步驟四每個狀態填可用操作和退出路徑欄">步驟四：每個狀態填「可用操作」和「退出路徑」欄&lt;/h3>
&lt;p>這一步是關鍵 — 操作盤點通常不提供這些資訊，需要主動補上。&lt;/p>
&lt;p>對每個狀態問兩個問題：&lt;/p>
&lt;ul>
&lt;li>使用者在這個狀態下想做什麼？（可用操作）&lt;/li>
&lt;li>使用者怎麼離開這個狀態？（退出路徑）&lt;/li>
&lt;/ul>
&lt;p>開發者容易假設 connected 狀態下使用者只想打字，不會想返回首頁。但使用者可能想切換到配對畫面重新配對、想暫時離開做其他事、想結束當前操作。把這些可能性列出來，判斷哪些需要提供操作按鈕。&lt;/p>
&lt;h3 id="步驟五檢查矩陣的空白格">步驟五：檢查矩陣的空白格&lt;/h3>
&lt;p>退出路徑欄為空的狀態是 UX 死胡同，需要補上退出路徑。可用操作欄為空的狀態需要判斷是否合理 — loading 狀態操作欄為空可能合理，但建議至少提供取消操作。&lt;/p>
&lt;h2 id="操作盤點的描述顯示偏差">操作盤點的「描述顯示」偏差&lt;/h2>
&lt;p>操作盤點的「前端引導」傾向描述顯示（What the user sees）而非描述互動（What the user can do）。這個偏差的根源在 BDD 的 Then 語法 — Then 通常描述可觀察的結果，而「畫面顯示 X」比「使用者可以做 Y」更容易寫成可觀察的斷言。&lt;/p>
&lt;p>app_tunnel 的操作盤點就是這個模式。四個操作情境的「前端引導」都寫了顯示內容（「顯示終端機畫面」「顯示連線中」「顯示無法連線」），沒有一個寫了操作（「使用者可以取消」「使用者可以返回」）或退出路徑（「使用者可以回到首頁」）（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>）。&lt;/p>
&lt;p>畫面狀態矩陣的四欄結構強制補上這兩個面向。從 BDD 操作盤點到畫面狀態矩陣的展開步驟，就是把「只描述顯示」擴展成「顯示 + 操作 + 退出」的過程。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>畫面狀態矩陣的完整定義 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法&lt;/a>&lt;/li>
&lt;li>路由可達性檢查 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性檢查&lt;/a>&lt;/li>
&lt;li>想知道什麼是「假設只走 happy path」的反模式 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/anti-pattern-happy-path-only/" data-link-title="反模式：假設使用者只走 happy path" data-link-desc="為什麼開發者容易只設計 happy path 的 UI、使用者在非 happy path 狀態下被困住的機制分析、以及用狀態矩陣系統性地防止這個問題">反模式：假設使用者只走 happy path&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>BDD 操作盤點描述使用者操作的情境和預期結果，但操作盤點的格式（Given / When / Then）聚焦在「什麼情境下做什麼得到什麼」，容易漏掉畫面層級的兩個面向：每個狀態下使用者能執行哪些操作，以及如何離開當前狀態。<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>補上這兩個面向，讓導航缺口在實作前浮現。</p>
<h2 id="操作盤點的覆蓋範圍">操作盤點的覆蓋範圍</h2>
<p>BDD 操作盤點通常包含：</p>
<ul>
<li>使用者操作（When）：「使用者點擊連線按鈕」</li>
<li>前端引導（Then）：「顯示連線進度指示」</li>
<li>後端回應：「WebSocket 連線建立」</li>
</ul>
<p>「前端引導」描述的是畫面的顯示內容 — 對應狀態矩陣的「顯示」欄。但操作盤點通常不會展開：連線中的畫面除了顯示進度指示，使用者能做什麼？如果連線失敗，使用者怎麼離開失敗畫面？</p>
<p>app_tunnel 的操作盤點在「前端引導」欄寫了「連線失敗顯示無法連線」，覆蓋了 error 狀態的顯示。但是「顯示無法連線之後使用者能做什麼」和「使用者怎麼離開這個畫面」都沒有描述。實作出來的 error 狀態有重連按鈕但沒有 back 按鈕 — 重連失敗時使用者被困在 error → retry → error 循環裡（<a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a>）。</p>
<h2 id="展開步驟">展開步驟</h2>
<h3 id="步驟一從操作盤點抽出所有畫面">步驟一：從操作盤點抽出所有畫面</h3>
<p>每個 BDD 情境至少涉及一個畫面。列出所有情境提到的畫面名稱，去重後得到畫面清單。</p>
<p>例：app_tunnel 的操作盤點涉及三個畫面 — 首頁、配對畫面、終端機畫面。</p>
<h3 id="步驟二每個畫面列出狀態">步驟二：每個畫面列出狀態</h3>
<p>從操作盤點的 Given / When / Then 條件中抽出狀態。「Given 連線已建立」暗示有 connected 狀態；「Then 顯示無法連線」暗示有 error 狀態。</p>
<p>同時檢查程式碼中的狀態 enum — 操作盤點可能遺漏了某些狀態（如 idle、disconnected），程式碼裡有但操作盤點沒提到的狀態同樣需要設計 UI。</p>
<h3 id="步驟三每個狀態填顯示欄">步驟三：每個狀態填「顯示」欄</h3>
<p>從操作盤點的「前端引導」直接填入。這一步通常不缺資料，因為操作盤點的強項就是描述顯示內容。</p>
<h3 id="步驟四每個狀態填可用操作和退出路徑欄">步驟四：每個狀態填「可用操作」和「退出路徑」欄</h3>
<p>這一步是關鍵 — 操作盤點通常不提供這些資訊，需要主動補上。</p>
<p>對每個狀態問兩個問題：</p>
<ul>
<li>使用者在這個狀態下想做什麼？（可用操作）</li>
<li>使用者怎麼離開這個狀態？（退出路徑）</li>
</ul>
<p>開發者容易假設 connected 狀態下使用者只想打字，不會想返回首頁。但使用者可能想切換到配對畫面重新配對、想暫時離開做其他事、想結束當前操作。把這些可能性列出來，判斷哪些需要提供操作按鈕。</p>
<h3 id="步驟五檢查矩陣的空白格">步驟五：檢查矩陣的空白格</h3>
<p>退出路徑欄為空的狀態是 UX 死胡同，需要補上退出路徑。可用操作欄為空的狀態需要判斷是否合理 — loading 狀態操作欄為空可能合理，但建議至少提供取消操作。</p>
<h2 id="操作盤點的描述顯示偏差">操作盤點的「描述顯示」偏差</h2>
<p>操作盤點的「前端引導」傾向描述顯示（What the user sees）而非描述互動（What the user can do）。這個偏差的根源在 BDD 的 Then 語法 — Then 通常描述可觀察的結果，而「畫面顯示 X」比「使用者可以做 Y」更容易寫成可觀察的斷言。</p>
<p>app_tunnel 的操作盤點就是這個模式。四個操作情境的「前端引導」都寫了顯示內容（「顯示終端機畫面」「顯示連線中」「顯示無法連線」），沒有一個寫了操作（「使用者可以取消」「使用者可以返回」）或退出路徑（「使用者可以回到首頁」）（<a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a>）。</p>
<p>畫面狀態矩陣的四欄結構強制補上這兩個面向。從 BDD 操作盤點到畫面狀態矩陣的展開步驟，就是把「只描述顯示」擴展成「顯示 + 操作 + 退出」的過程。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>畫面狀態矩陣的完整定義 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</a></li>
<li>路由可達性檢查 → <a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">路由可達性檢查</a></li>
<li>想知道什麼是「假設只走 happy path」的反模式 → <a href="/blog/ux-design/01-screen-state-machine/anti-pattern-happy-path-only/" data-link-title="反模式：假設使用者只走 happy path" data-link-desc="為什麼開發者容易只設計 happy path 的 UI、使用者在非 happy path 狀態下被困住的機制分析、以及用狀態矩陣系統性地防止這個問題">反模式：假設使用者只走 happy path</a></li>
</ul>
]]></content:encoded></item><item><title>模組二：Gate 與 Fallback 設計</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/</guid><description>&lt;p>回答「使用者過不了關卡時怎麼辦」。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UF-4&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>&lt;/td>
 &lt;td>biometricOnly 安全收益 vs 可用性代價 — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UF-5&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>&lt;/td>
 &lt;td>開發環境遮蔽 gate 問題（模擬器行為 vs 真機）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Gate 分類與三問設計法（成功 / 失敗 / 使用者不知道發生什麼）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Biometric fallback 完整設計（iOS/Android 差異）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 網路斷線 UX 模式（offline-first / retry / degraded mode）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Permission 請求時機與措辭&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 開發環境 vs 真機的 gate 行為差異表&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略&lt;/a>：gate fallback 的 mock vs 真機行為差異需要 protocol test&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：biometric fallback 的安全 vs 可用性取捨&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「使用者過不了關卡時怎麼辦」。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UF-4</td>
          <td><a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a></td>
          <td>biometricOnly 安全收益 vs 可用性代價 — <strong>本模組主寫</strong></td>
      </tr>
      <tr>
          <td>UF-5</td>
          <td><a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a></td>
          <td>開發環境遮蔽 gate 問題（模擬器行為 vs 真機）</td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Gate 分類與三問設計法（成功 / 失敗 / 使用者不知道發生什麼）</li>
<li><input checked="" disabled="" type="checkbox"> Biometric fallback 完整設計（iOS/Android 差異）</li>
<li><input checked="" disabled="" type="checkbox"> 網路斷線 UX 模式（offline-first / retry / degraded mode）</li>
<li><input checked="" disabled="" type="checkbox"> Permission 請求時機與措辭</li>
<li><input checked="" disabled="" type="checkbox"> 開發環境 vs 真機的 gate 行為差異表</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略</a>：gate fallback 的 mock vs 真機行為差異需要 protocol test</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：biometric fallback 的安全 vs 可用性取捨</li>
</ul>
]]></content:encoded></item><item><title>Degraded mode 設計</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/degraded-mode-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/degraded-mode-design/</guid><description>&lt;p>Degraded mode 是指系統的部分功能因為外部依賴不可用（網路斷線、服務故障、權限缺失）而暫時無法運作，但其他功能仍可正常使用。設計重點是讓使用者清楚知道哪些功能可用、哪些暫時不可用，而非讓整個 app 因為一個功能的失敗而停擺。&lt;/p>
&lt;h2 id="三種處理方式">三種處理方式&lt;/h2>
&lt;h3 id="靜默隱藏">靜默隱藏&lt;/h3>
&lt;p>把不可用的功能從 UI 上移除 — 按鈕消失、選單項目隱藏。使用者看不到這些功能，自然不會嘗試使用。&lt;/p>
&lt;p>靜默隱藏的風險是使用者困惑。經常使用的功能突然消失，使用者會以為是 bug 或 app 更新移除了功能。如果功能在恢復後重新出現，使用者的困惑加劇。&lt;/p>
&lt;p>靜默隱藏只適合使用者從未使用過的功能（新使用者尚未配對時隱藏連線按鈕）。已經使用過的功能突然隱藏會破壞使用者的心理模型。&lt;/p>
&lt;h3 id="明確標示">明確標示&lt;/h3>
&lt;p>功能的 UI 元素保留在畫面上，但加上不可用的視覺標示 — 灰色按鈕、「離線不可用」標籤、禁用狀態。使用者能看到功能存在，也知道目前暫時無法使用。&lt;/p>
&lt;p>明確標示的設計要點：&lt;/p>
&lt;p>&lt;strong>說明原因&lt;/strong>。「搜尋功能需要網路連線」比單純的灰色按鈕提供更多資訊。使用者知道原因後能自行判斷要等還是離開。&lt;/p>
&lt;p>&lt;strong>說明恢復條件&lt;/strong>。「連上網路後自動恢復」讓使用者知道什麼時候功能會回來。「重新啟動 app 後可用」讓使用者知道需要採取行動。&lt;/p>
&lt;p>&lt;strong>避免只靠顏色傳達狀態&lt;/strong>。灰色按鈕對色盲使用者可能不明顯。搭配文字標籤或圖示。&lt;/p>
&lt;h3 id="替代方案">替代方案&lt;/h3>
&lt;p>提供不需要失敗依賴的替代功能。線上搜尋不可用時提供離線搜尋（本地快取的資料）。即時同步不可用時提供本地儲存（恢復連線後自動同步）。&lt;/p>
&lt;p>替代方案的 UX 需要讓使用者知道目前使用的是替代版本。「離線模式 — 搜尋結果可能不是最新的」讓使用者對結果的準確度有正確預期。&lt;/p>
&lt;h2 id="全域-vs-功能級降級">全域 vs 功能級降級&lt;/h2>
&lt;h3 id="全域降級">全域降級&lt;/h3>
&lt;p>整個 app 進入降級模式 — 頂部顯示「離線模式」橫幅，所有需要網路的功能統一標示為不可用。適合網路連線是 app 核心依賴的場景。&lt;/p>
&lt;p>全域降級的 UI 實作簡單（一個全域狀態控制所有功能的可用性），但可能過度限制 — 部分功能不依賴網路也能運作。&lt;/p>
&lt;h3 id="功能級降級">功能級降級&lt;/h3>
&lt;p>每個功能獨立判斷自己的可用狀態。搜尋需要網路但筆記不需要 — 網路斷線時搜尋不可用，筆記正常。&lt;/p>
&lt;p>功能級降級更精確但實作更複雜 — 每個功能需要宣告自己的依賴，並在依賴不可用時提供對應的 UI 狀態。&lt;/p>
&lt;h2 id="降級狀態的進入和退出">降級狀態的進入和退出&lt;/h2>
&lt;h3 id="進入">進入&lt;/h3>
&lt;p>依賴不可用時自動進入降級狀態。進入時通知使用者（Snackbar、橫幅、狀態變更）。&lt;/p>
&lt;p>避免頻繁切換 — 網路訊號不穩定時，每秒在正常和降級之間切換會讓 UI 閃爍。加入穩定性判斷（連續 N 秒不可用才進入降級，連續 N 秒可用才退出降級）。&lt;/p>
&lt;h3 id="退出">退出&lt;/h3>
&lt;p>依賴恢復後自動退出降級狀態。退出時通知使用者（「已恢復連線」），並自動執行待完成的操作（同步、補發事件）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>錯誤訊息的撰寫 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-message-principles/" data-link-title="錯誤訊息撰寫原則" data-link-desc="錯誤訊息的兩個職責：使用者能讀懂發生什麼、使用者能決定下一步做什麼">錯誤訊息撰寫原則&lt;/a>&lt;/li>
&lt;li>重試循環的逃生口 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口&lt;/a>&lt;/li>
&lt;li>網路 gate 的 UX 處理 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/network-offline-ux/" data-link-title="網路斷線 UX 模式" data-link-desc="Offline-first / retry / degraded mode 三種網路 gate 的處理策略 — 取決於功能是否依賴即時連線">ux-design 模組二 網路斷線 UX 模式&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Degraded mode 是指系統的部分功能因為外部依賴不可用（網路斷線、服務故障、權限缺失）而暫時無法運作，但其他功能仍可正常使用。設計重點是讓使用者清楚知道哪些功能可用、哪些暫時不可用，而非讓整個 app 因為一個功能的失敗而停擺。</p>
<h2 id="三種處理方式">三種處理方式</h2>
<h3 id="靜默隱藏">靜默隱藏</h3>
<p>把不可用的功能從 UI 上移除 — 按鈕消失、選單項目隱藏。使用者看不到這些功能，自然不會嘗試使用。</p>
<p>靜默隱藏的風險是使用者困惑。經常使用的功能突然消失，使用者會以為是 bug 或 app 更新移除了功能。如果功能在恢復後重新出現，使用者的困惑加劇。</p>
<p>靜默隱藏只適合使用者從未使用過的功能（新使用者尚未配對時隱藏連線按鈕）。已經使用過的功能突然隱藏會破壞使用者的心理模型。</p>
<h3 id="明確標示">明確標示</h3>
<p>功能的 UI 元素保留在畫面上，但加上不可用的視覺標示 — 灰色按鈕、「離線不可用」標籤、禁用狀態。使用者能看到功能存在，也知道目前暫時無法使用。</p>
<p>明確標示的設計要點：</p>
<p><strong>說明原因</strong>。「搜尋功能需要網路連線」比單純的灰色按鈕提供更多資訊。使用者知道原因後能自行判斷要等還是離開。</p>
<p><strong>說明恢復條件</strong>。「連上網路後自動恢復」讓使用者知道什麼時候功能會回來。「重新啟動 app 後可用」讓使用者知道需要採取行動。</p>
<p><strong>避免只靠顏色傳達狀態</strong>。灰色按鈕對色盲使用者可能不明顯。搭配文字標籤或圖示。</p>
<h3 id="替代方案">替代方案</h3>
<p>提供不需要失敗依賴的替代功能。線上搜尋不可用時提供離線搜尋（本地快取的資料）。即時同步不可用時提供本地儲存（恢復連線後自動同步）。</p>
<p>替代方案的 UX 需要讓使用者知道目前使用的是替代版本。「離線模式 — 搜尋結果可能不是最新的」讓使用者對結果的準確度有正確預期。</p>
<h2 id="全域-vs-功能級降級">全域 vs 功能級降級</h2>
<h3 id="全域降級">全域降級</h3>
<p>整個 app 進入降級模式 — 頂部顯示「離線模式」橫幅，所有需要網路的功能統一標示為不可用。適合網路連線是 app 核心依賴的場景。</p>
<p>全域降級的 UI 實作簡單（一個全域狀態控制所有功能的可用性），但可能過度限制 — 部分功能不依賴網路也能運作。</p>
<h3 id="功能級降級">功能級降級</h3>
<p>每個功能獨立判斷自己的可用狀態。搜尋需要網路但筆記不需要 — 網路斷線時搜尋不可用，筆記正常。</p>
<p>功能級降級更精確但實作更複雜 — 每個功能需要宣告自己的依賴，並在依賴不可用時提供對應的 UI 狀態。</p>
<h2 id="降級狀態的進入和退出">降級狀態的進入和退出</h2>
<h3 id="進入">進入</h3>
<p>依賴不可用時自動進入降級狀態。進入時通知使用者（Snackbar、橫幅、狀態變更）。</p>
<p>避免頻繁切換 — 網路訊號不穩定時，每秒在正常和降級之間切換會讓 UI 閃爍。加入穩定性判斷（連續 N 秒不可用才進入降級，連續 N 秒可用才退出降級）。</p>
<h3 id="退出">退出</h3>
<p>依賴恢復後自動退出降級狀態。退出時通知使用者（「已恢復連線」），並自動執行待完成的操作（同步、補發事件）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>錯誤訊息的撰寫 → <a href="/blog/ux-design/04-error-recovery/error-message-principles/" data-link-title="錯誤訊息撰寫原則" data-link-desc="錯誤訊息的兩個職責：使用者能讀懂發生什麼、使用者能決定下一步做什麼">錯誤訊息撰寫原則</a></li>
<li>重試循環的逃生口 → <a href="/blog/ux-design/04-error-recovery/error-loop-escape/" data-link-title="error → retry → error 循環的逃生口設計" data-link-desc="當重試持續失敗時，使用者需要第二條路 — 逃生口設計讓使用者能離開失敗循環而非被困住">error → retry → error 循環的逃生口</a></li>
<li>網路 gate 的 UX 處理 → <a href="/blog/ux-design/02-gate-fallback/network-offline-ux/" data-link-title="網路斷線 UX 模式" data-link-desc="Offline-first / retry / degraded mode 三種網路 gate 的處理策略 — 取決於功能是否依賴即時連線">ux-design 模組二 網路斷線 UX 模式</a></li>
</ul>
]]></content:encoded></item><item><title>Fallback（UX）</title><link>https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/knowledge-cards/ux-fallback/</guid><description>&lt;p>UX fallback 的核心概念是「gate 未通過時使用者的替代路徑」。替代路徑可以是替代驗證方式（密碼代替 Face ID）、降級功能（部分功能可用）、手動重試、或放棄操作返回上一頁。和 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">Fallback（Backend）&lt;/a> 不同，UX fallback 關注的是使用者體驗層的路徑設計，而非 server-side 的服務降級策略。可先對照 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate&lt;/a>。&lt;/p>
&lt;h2 id="概念位置">概念位置&lt;/h2>
&lt;p>UX fallback 位在 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate&lt;/a> 設計的失敗路徑中。Gate 的三問（成功/失敗/不確定）中，失敗路徑的具體內容就是 UX fallback。Backend 的 &lt;a href="https://tarrragon.github.io/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback&lt;/a> 是系統在依賴失敗時用替代結果維持服務，UX fallback 是使用者在 gate 失敗時的操作替代方案。兩者可能並存 — server-side fallback 提供降級資料，UX fallback 決定如何呈現這些降級資料給使用者。&lt;/p>
&lt;h2 id="可觀察訊號與例子">可觀察訊號與例子&lt;/h2>
&lt;p>需要 UX fallback 的訊號是 gate 失敗時使用者完全無法繼續。常見情境：biometric 設定 &lt;code>biometricOnly: true&lt;/code> 導致 Face ID 失敗時沒有密碼 fallback、error 畫面只有重試按鈕沒有返回按鈕、網路斷線後所有功能不可用但部分功能不依賴網路。&lt;/p>
&lt;h2 id="設計責任">設計責任&lt;/h2>
&lt;p>UX fallback 的設計責任是確保 gate 失敗時使用者有路可走。Fallback 的選擇取決於安全需求和使用場景 — 銀行 app 可能不提供低安全等級的 fallback，自用工具可以接受密碼 fallback 因為使用者就是 owner。安全 vs 可用性的取捨應在功能規格中顯式記錄。UX fallback 的存在應反映在&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>的退出路徑欄中。&lt;/p></description><content:encoded><![CDATA[<p>UX fallback 的核心概念是「gate 未通過時使用者的替代路徑」。替代路徑可以是替代驗證方式（密碼代替 Face ID）、降級功能（部分功能可用）、手動重試、或放棄操作返回上一頁。和 <a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">Fallback（Backend）</a> 不同，UX fallback 關注的是使用者體驗層的路徑設計，而非 server-side 的服務降級策略。可先對照 <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate</a>。</p>
<h2 id="概念位置">概念位置</h2>
<p>UX fallback 位在 <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">Gate</a> 設計的失敗路徑中。Gate 的三問（成功/失敗/不確定）中，失敗路徑的具體內容就是 UX fallback。Backend 的 <a href="/blog/backend/knowledge-cards/fallback/" data-link-title="Fallback" data-link-desc="說明主要路徑失敗時使用替代結果或替代流程的設計責任">fallback</a> 是系統在依賴失敗時用替代結果維持服務，UX fallback 是使用者在 gate 失敗時的操作替代方案。兩者可能並存 — server-side fallback 提供降級資料，UX fallback 決定如何呈現這些降級資料給使用者。</p>
<h2 id="可觀察訊號與例子">可觀察訊號與例子</h2>
<p>需要 UX fallback 的訊號是 gate 失敗時使用者完全無法繼續。常見情境：biometric 設定 <code>biometricOnly: true</code> 導致 Face ID 失敗時沒有密碼 fallback、error 畫面只有重試按鈕沒有返回按鈕、網路斷線後所有功能不可用但部分功能不依賴網路。</p>
<h2 id="設計責任">設計責任</h2>
<p>UX fallback 的設計責任是確保 gate 失敗時使用者有路可走。Fallback 的選擇取決於安全需求和使用場景 — 銀行 app 可能不提供低安全等級的 fallback，自用工具可以接受密碼 fallback 因為使用者就是 owner。安全 vs 可用性的取捨應在功能規格中顯式記錄。UX fallback 的存在應反映在<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>的退出路徑欄中。</p>
]]></content:encoded></item><item><title>iOS HIG vs Material Design 導航差異</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/ios-vs-material-navigation/</guid><description>&lt;p>iOS Human Interface Guidelines（HIG）和 Material Design 對導航行為有不同的慣例。跨平台 app（Flutter、React Native）需要決定：完全遵循一套、各平台遵循各自的慣例、或混合使用。這個決策影響使用者在不同平台上的操作體驗。&lt;/p>
&lt;h2 id="back-行為">Back 行為&lt;/h2>
&lt;h3 id="ios">iOS&lt;/h3>
&lt;p>iOS 沒有系統級的 back 按鈕。導航列左上角的 back 按鈕由 app 提供（&lt;code>UINavigationController&lt;/code> 自動加入）。使用者也可以從螢幕左邊緣向右滑動觸發 back（edge swipe gesture）。&lt;/p>
&lt;p>iOS 的 back 行為是 pop — 彈出堆疊頂端，回到前一個畫面。沒有 Android 的系統 back 按鈕覆寫機制。&lt;/p>
&lt;h3 id="android--material-design">Android / Material Design&lt;/h3>
&lt;p>Android 有系統級的 back 按鈕（虛擬或實體）。Material Design 在 app bar 左上角也放 back 箭頭或 hamburger menu 圖示。&lt;/p>
&lt;p>Android 的 back 行為由 app 控制（&lt;code>onBackPressed&lt;/code>），可以被覆寫。常見的覆寫場景：在首頁按 back 詢問「是否離開 app」、在表單中按 back 詢問「是否放棄編輯」。&lt;/p>
&lt;h3 id="跨平台決策">跨平台決策&lt;/h3>
&lt;p>Flutter 預設在 Android 上攔截系統 back 按鈕，在 iOS 上提供 back 按鈕和 edge swipe。GoRouter 的 &lt;code>pop()&lt;/code> 在兩個平台上行為一致。&lt;/p>
&lt;p>跨平台 app 需要注意的差異：iOS 使用者習慣 edge swipe back，Android 使用者習慣按系統 back 按鈕。兩者都要支援。&lt;/p>
&lt;h2 id="tab-bar-位置">Tab bar 位置&lt;/h2>
&lt;h3 id="ios-1">iOS&lt;/h3>
&lt;p>Tab bar 固定在畫面底部。iOS 使用者期望 tab bar 永遠可見、永遠在底部。Apple 的 HIG 明確建議 tab bar 在底部。&lt;/p>
&lt;h3 id="material-design">Material Design&lt;/h3>
&lt;p>Material Design 的 bottom navigation 也在底部，但額外支援 top tabs（在 app bar 下方的可滑動標籤列）。Top tabs 適合同一類內容的不同視角（全部 / 未讀 / 已標記）。&lt;/p>
&lt;h3 id="跨平台決策-1">跨平台決策&lt;/h3>
&lt;p>底部 tab bar 在兩個平台上都是標準做法。Top tabs 在 iOS 上較少見（iOS 偏好用 segmented control 代替 top tabs）。跨平台 app 用底部 tab bar 是最安全的選擇。&lt;/p>
&lt;h2 id="modal-呈現">Modal 呈現&lt;/h2>
&lt;h3 id="ios-2">iOS&lt;/h3>
&lt;p>iOS 的 modal 畫面從底部滑上來，覆蓋前一個畫面但不完全遮擋（iOS 13+ 的 sheet 呈現樣式可以看到前一個畫面的上緣）。Dismiss 操作是向下滑動或點擊關閉按鈕。&lt;/p>
&lt;h3 id="material-design-1">Material Design&lt;/h3>
&lt;p>Material Design 的 bottom sheet 和 dialog 是 modal 的主要形式。Full-screen dialog 從底部滑上來，有 close 按鈕在左上角和 action 按鈕在右上角。&lt;/p>
&lt;h3 id="跨平台決策-2">跨平台決策&lt;/h3>
&lt;p>Flutter 的 &lt;code>showModalBottomSheet&lt;/code> 和 &lt;code>showDialog&lt;/code> 在兩個平台上都可用。視覺呈現可以用 platform-adaptive widget（&lt;code>CupertinoPageRoute&lt;/code> vs &lt;code>MaterialPageRoute&lt;/code>）按平台切換。&lt;/p>
&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>統一用 Material Design&lt;/td>
 &lt;td>以 Android 為主的 app、快速開發&lt;/td>
 &lt;td>iOS 使用者體驗不原生&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>統一用 iOS HIG&lt;/td>
 &lt;td>以 iOS 為主的 app&lt;/td>
 &lt;td>Android 使用者體驗不原生&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>多數跨平台 app 的實際選擇&lt;/td>
 &lt;td>需要判斷哪些差異值得適配&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>多數跨平台 app 選擇「共用核心、差異點適配」— 底部 tab bar、push/pop 導航在兩個平台上一致；back 手勢、modal 呈現按平台適配。&lt;/p></description><content:encoded><![CDATA[<p>iOS Human Interface Guidelines（HIG）和 Material Design 對導航行為有不同的慣例。跨平台 app（Flutter、React Native）需要決定：完全遵循一套、各平台遵循各自的慣例、或混合使用。這個決策影響使用者在不同平台上的操作體驗。</p>
<h2 id="back-行為">Back 行為</h2>
<h3 id="ios">iOS</h3>
<p>iOS 沒有系統級的 back 按鈕。導航列左上角的 back 按鈕由 app 提供（<code>UINavigationController</code> 自動加入）。使用者也可以從螢幕左邊緣向右滑動觸發 back（edge swipe gesture）。</p>
<p>iOS 的 back 行為是 pop — 彈出堆疊頂端，回到前一個畫面。沒有 Android 的系統 back 按鈕覆寫機制。</p>
<h3 id="android--material-design">Android / Material Design</h3>
<p>Android 有系統級的 back 按鈕（虛擬或實體）。Material Design 在 app bar 左上角也放 back 箭頭或 hamburger menu 圖示。</p>
<p>Android 的 back 行為由 app 控制（<code>onBackPressed</code>），可以被覆寫。常見的覆寫場景：在首頁按 back 詢問「是否離開 app」、在表單中按 back 詢問「是否放棄編輯」。</p>
<h3 id="跨平台決策">跨平台決策</h3>
<p>Flutter 預設在 Android 上攔截系統 back 按鈕，在 iOS 上提供 back 按鈕和 edge swipe。GoRouter 的 <code>pop()</code> 在兩個平台上行為一致。</p>
<p>跨平台 app 需要注意的差異：iOS 使用者習慣 edge swipe back，Android 使用者習慣按系統 back 按鈕。兩者都要支援。</p>
<h2 id="tab-bar-位置">Tab bar 位置</h2>
<h3 id="ios-1">iOS</h3>
<p>Tab bar 固定在畫面底部。iOS 使用者期望 tab bar 永遠可見、永遠在底部。Apple 的 HIG 明確建議 tab bar 在底部。</p>
<h3 id="material-design">Material Design</h3>
<p>Material Design 的 bottom navigation 也在底部，但額外支援 top tabs（在 app bar 下方的可滑動標籤列）。Top tabs 適合同一類內容的不同視角（全部 / 未讀 / 已標記）。</p>
<h3 id="跨平台決策-1">跨平台決策</h3>
<p>底部 tab bar 在兩個平台上都是標準做法。Top tabs 在 iOS 上較少見（iOS 偏好用 segmented control 代替 top tabs）。跨平台 app 用底部 tab bar 是最安全的選擇。</p>
<h2 id="modal-呈現">Modal 呈現</h2>
<h3 id="ios-2">iOS</h3>
<p>iOS 的 modal 畫面從底部滑上來，覆蓋前一個畫面但不完全遮擋（iOS 13+ 的 sheet 呈現樣式可以看到前一個畫面的上緣）。Dismiss 操作是向下滑動或點擊關閉按鈕。</p>
<h3 id="material-design-1">Material Design</h3>
<p>Material Design 的 bottom sheet 和 dialog 是 modal 的主要形式。Full-screen dialog 從底部滑上來，有 close 按鈕在左上角和 action 按鈕在右上角。</p>
<h3 id="跨平台決策-2">跨平台決策</h3>
<p>Flutter 的 <code>showModalBottomSheet</code> 和 <code>showDialog</code> 在兩個平台上都可用。視覺呈現可以用 platform-adaptive widget（<code>CupertinoPageRoute</code> vs <code>MaterialPageRoute</code>）按平台切換。</p>
<h2 id="選擇策略">選擇策略</h2>
<table>
  <thead>
      <tr>
          <th>策略</th>
          <th>適合場景</th>
          <th>代價</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>統一用 Material Design</td>
          <td>以 Android 為主的 app、快速開發</td>
          <td>iOS 使用者體驗不原生</td>
      </tr>
      <tr>
          <td>統一用 iOS HIG</td>
          <td>以 iOS 為主的 app</td>
          <td>Android 使用者體驗不原生</td>
      </tr>
      <tr>
          <td>各平台遵循各自慣例</td>
          <td>重視兩個平台原生體驗</td>
          <td>開發和測試成本翻倍</td>
      </tr>
      <tr>
          <td>共用核心、差異點適配</td>
          <td>多數跨平台 app 的實際選擇</td>
          <td>需要判斷哪些差異值得適配</td>
      </tr>
  </tbody>
</table>
<p>多數跨平台 app 選擇「共用核心、差異點適配」— 底部 tab bar、push/pop 導航在兩個平台上一致；back 手勢、modal 呈現按平台適配。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Deep link 設計 → <a href="/blog/ux-design/05-navigation-patterns/deep-link-design/" data-link-title="Deep link 設計" data-link-desc="URL scheme / Universal Link / App Link — deep link 讓外部來源直接導航到 app 的特定畫面">Deep link 設計</a></li>
<li>go vs push 的語意差異 → <a href="/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push vs pushReplacement 語意表</a></li>
<li>導航模式分類 → <a href="/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/" data-link-title="Mobile 導航模式分類" data-link-desc="Push/pop stack / declarative router / tab bar / drawer — 四種 mobile 導航模式各自的適用場景和使用者心理模型">Mobile 導航模式分類</a></li>
</ul>
]]></content:encoded></item><item><title>U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField</title><link>https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/</guid><description>&lt;p>這個案例的核心責任是說明輸入機制是設計產物（在企劃階段決定），不是實作細節（在寫 code 時順便加）。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的 Terminal 畫面在 W2 修復前沒有任何文字輸入元件。使用者只能透過底部工具列的特殊鍵（Esc/Tab/Ctrl/方向鍵）操作終端機，無法打字。&lt;/p>
&lt;p>W2-001 修復時加入的 &lt;code>TextField&lt;/code> 及其參數：&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">TextField&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="nl">keyboardType:&lt;/span> &lt;span class="n">TextInputType&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">visiblePassword&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">3&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">enableSuggestions:&lt;/span> &lt;span class="kc">false&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">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">autocorrect:&lt;/span> &lt;span class="kc">false&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">5&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">enableIMEPersonalizedLearning:&lt;/span> &lt;span class="kc">false&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 關閉 IME 個人化學習
&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="nl">onSubmitted:&lt;/span> &lt;span class="n">_submitInput&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// Enter 送出整行
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="nl">textInputAction:&lt;/span> &lt;span class="n">TextInputAction&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">send&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>每個參數都是一個設計決策，但沒有一個是事前規劃的 — 全部是寫 code 時臨時判斷。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>設計決策&lt;/th>
 &lt;th>事前規劃&lt;/th>
 &lt;th>事後 hotfix 的風險&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>visiblePassword&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>如果用預設 &lt;code>text&lt;/code>，iOS 會自動校正 &lt;code>ls -la&lt;/code> 成其他東西&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>enableSuggestions: false&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>建議列遮擋終端機畫面下方&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>autocorrect: false&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>路徑 &lt;code>/usr/bin/&lt;/code> 可能被校正&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>enableIMEPersonalizedLearning: false&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>CLI 輸入含密碼和路徑，IME 學習是安全風險&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>onSubmitted&lt;/code>（整行送出）&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>如果逐字元送出，Tab 補全和命令編輯需要完全不同的 protocol 設計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>TextInputAction.send&lt;/code>&lt;/td>
 &lt;td>沒有&lt;/td>
 &lt;td>如果用 &lt;code>newline&lt;/code>，使用者按 Enter 會換行不送出&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>輸入設計影響 UI layout 和 protocol&lt;/strong>。&lt;code>onSubmitted&lt;/code>（整行送出）vs 逐字元即時送出不只是 UI 問題 — 整行送出代表 protocol 層送的是 &lt;code>command\n&lt;/code>，逐字元送出代表每個按鍵都是一個 WS frame。這個決策應該在 protocol spec 階段就做，因為它影響 server 端的行為預期。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>IME 控制有安全意涵&lt;/strong>。&lt;code>enableIMEPersonalizedLearning: false&lt;/code> 不只是 UX 偏好 — CLI 輸入可能包含資料庫密碼、API key、伺服器路徑。IME 學習這些內容等於把 secret 存到了 IME 的詞庫裡，跨 app 可用。這是安全問題，不是 UX 問題。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>事後 hotfix 的六個參數每個都有 gotcha&lt;/strong>。如果這些決策在企劃階段做，可以寫成決策表並在 code review 時對照。事後 hotfix 時開發者可能漏掉其中一兩個（例如只加 &lt;code>autocorrect: false&lt;/code> 但忘了 &lt;code>enableIMEPersonalizedLearning: false&lt;/code>），漏掉的那個就成為安全漏洞。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>功能規格新增「輸入機制決策表」&lt;/strong>：keyboard type / submit model / IME policy / special keys 四個維度，每個列出選項和取捨理由。&lt;/li>
&lt;li>&lt;strong>輸入機制跟 protocol 一起設計&lt;/strong>：「整行送出」還是「逐字元」決定了 WS 訊框的設計，必須在 protocol spec 階段決定。&lt;/li>
&lt;li>&lt;strong>安全敏感參數強制列入 review checklist&lt;/strong>：&lt;code>enableIMEPersonalizedLearning&lt;/code>、&lt;code>autocorrect&lt;/code> 在處理 secret 的輸入框中是安全要求，不是可選項。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計 mobile 輸入機制 → &lt;a href="https://tarrragon.github.io/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">輸入機制決策表&lt;/a>&lt;/li>
&lt;li>想看 protocol 跟輸入的關聯 → &lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1 WS frame type&lt;/a>（sendData 的型別決策）&lt;/li>
&lt;li>想做安全審查 → 待補：CLI 輸入安全 checklist&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明輸入機制是設計產物（在企劃階段決定），不是實作細節（在寫 code 時順便加）。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的 Terminal 畫面在 W2 修復前沒有任何文字輸入元件。使用者只能透過底部工具列的特殊鍵（Esc/Tab/Ctrl/方向鍵）操作終端機，無法打字。</p>
<p>W2-001 修復時加入的 <code>TextField</code> 及其參數：</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">TextField</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nl">keyboardType:</span> <span class="n">TextInputType</span><span class="p">.</span><span class="n">visiblePassword</span><span class="p">,</span>   <span class="c1">// 避免自動校正
</span></span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"></span>  <span class="nl">enableSuggestions:</span> <span class="kc">false</span><span class="p">,</span>                       <span class="c1">// 關閉建議列
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="nl">autocorrect:</span> <span class="kc">false</span><span class="p">,</span>                             <span class="c1">// 關閉自動校正
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span>  <span class="nl">enableIMEPersonalizedLearning:</span> <span class="kc">false</span><span class="p">,</span>           <span class="c1">// 關閉 IME 個人化學習
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="nl">onSubmitted:</span> <span class="n">_submitInput</span><span class="p">,</span>                      <span class="c1">// Enter 送出整行
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="nl">textInputAction:</span> <span class="n">TextInputAction</span><span class="p">.</span><span class="n">send</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>每個參數都是一個設計決策，但沒有一個是事前規劃的 — 全部是寫 code 時臨時判斷。</p>
<table>
  <thead>
      <tr>
          <th>設計決策</th>
          <th>事前規劃</th>
          <th>事後 hotfix 的風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>visiblePassword</code></td>
          <td>沒有</td>
          <td>如果用預設 <code>text</code>，iOS 會自動校正 <code>ls -la</code> 成其他東西</td>
      </tr>
      <tr>
          <td><code>enableSuggestions: false</code></td>
          <td>沒有</td>
          <td>建議列遮擋終端機畫面下方</td>
      </tr>
      <tr>
          <td><code>autocorrect: false</code></td>
          <td>沒有</td>
          <td>路徑 <code>/usr/bin/</code> 可能被校正</td>
      </tr>
      <tr>
          <td><code>enableIMEPersonalizedLearning: false</code></td>
          <td>沒有</td>
          <td>CLI 輸入含密碼和路徑，IME 學習是安全風險</td>
      </tr>
      <tr>
          <td><code>onSubmitted</code>（整行送出）</td>
          <td>沒有</td>
          <td>如果逐字元送出，Tab 補全和命令編輯需要完全不同的 protocol 設計</td>
      </tr>
      <tr>
          <td><code>TextInputAction.send</code></td>
          <td>沒有</td>
          <td>如果用 <code>newline</code>，使用者按 Enter 會換行不送出</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>輸入設計影響 UI layout 和 protocol</strong>。<code>onSubmitted</code>（整行送出）vs 逐字元即時送出不只是 UI 問題 — 整行送出代表 protocol 層送的是 <code>command\n</code>，逐字元送出代表每個按鍵都是一個 WS frame。這個決策應該在 protocol spec 階段就做，因為它影響 server 端的行為預期。</p>
</li>
<li>
<p><strong>IME 控制有安全意涵</strong>。<code>enableIMEPersonalizedLearning: false</code> 不只是 UX 偏好 — CLI 輸入可能包含資料庫密碼、API key、伺服器路徑。IME 學習這些內容等於把 secret 存到了 IME 的詞庫裡，跨 app 可用。這是安全問題，不是 UX 問題。</p>
</li>
<li>
<p><strong>事後 hotfix 的六個參數每個都有 gotcha</strong>。如果這些決策在企劃階段做，可以寫成決策表並在 code review 時對照。事後 hotfix 時開發者可能漏掉其中一兩個（例如只加 <code>autocorrect: false</code> 但忘了 <code>enableIMEPersonalizedLearning: false</code>），漏掉的那個就成為安全漏洞。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>功能規格新增「輸入機制決策表」</strong>：keyboard type / submit model / IME policy / special keys 四個維度，每個列出選項和取捨理由。</li>
<li><strong>輸入機制跟 protocol 一起設計</strong>：「整行送出」還是「逐字元」決定了 WS 訊框的設計，必須在 protocol spec 階段決定。</li>
<li><strong>安全敏感參數強制列入 review checklist</strong>：<code>enableIMEPersonalizedLearning</code>、<code>autocorrect</code> 在處理 secret 的輸入框中是安全要求，不是可選項。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計 mobile 輸入機制 → <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></li>
<li>想看 protocol 跟輸入的關聯 → <a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1 WS frame type</a>（sendData 的型別決策）</li>
<li>想做安全審查 → 待補：CLI 輸入安全 checklist</li>
</ul>
]]></content:encoded></item><item><title>表單 UX 模式</title><link>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/form-ux-pattern/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/form-ux-pattern/</guid><description>&lt;p>表單輸入和 terminal 輸入用同一套四維度決策框架（keyboard type / submit model / IME policy / special keys），但每個維度的選項和取捨方向不同。表單場景的使用者輸入的是結構化但自然語言為主的內容 — 姓名、email、地址 — 手機鍵盤的自動行為在這個場景中大部分是幫助。&lt;/p>
&lt;h2 id="keyboard-type-的選擇">Keyboard type 的選擇&lt;/h2>
&lt;p>表單的每個欄位應該使用最適合該欄位內容的鍵盤。正確的 keyboard type 減少使用者在鍵盤上找按鍵的時間，也讓自動填入和驗證更準確。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>欄位&lt;/th>
 &lt;th>Keyboard type&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Email&lt;/td>
 &lt;td>emailAddress&lt;/td>
 &lt;td>有 &lt;code>@&lt;/code> 和 &lt;code>.&lt;/code> 快捷鍵&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>電話號碼&lt;/td>
 &lt;td>phone&lt;/td>
 &lt;td>只顯示數字和 &lt;code>+&lt;/code> &lt;code>-&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>URL&lt;/td>
 &lt;td>url&lt;/td>
 &lt;td>有 &lt;code>.com&lt;/code> 快捷鍵和 &lt;code>/&lt;/code> 鍵&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>密碼&lt;/td>
 &lt;td>visiblePassword&lt;/td>
 &lt;td>關閉自動校正，保留字元可見控制&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>搜尋&lt;/td>
 &lt;td>text&lt;/td>
 &lt;td>一般文字，可搭配 &lt;code>textInputAction: search&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>數字金額&lt;/td>
 &lt;td>numberWithOptions(decimal: true)&lt;/td>
 &lt;td>數字鍵盤加小數點&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="驗證時機">驗證時機&lt;/h2>
&lt;p>表單驗證的時機影響使用者的操作流暢度和錯誤修正效率。&lt;/p>
&lt;h3 id="即時驗證on-change">即時驗證（on change）&lt;/h3>
&lt;p>每次輸入變化時驗證。適合格式明確的欄位（email 格式、手機號碼長度）。即時驗證在使用者輸入過程中就能回饋格式錯誤，不需要等到送出。&lt;/p>
&lt;p>即時驗證的風險是過早報錯。使用者正在輸入 email 地址 &lt;code>user@&lt;/code> 時，缺少 domain 部分 — 這個時候報「email 格式錯誤」對使用者沒有幫助。解法是在欄位失去焦點（on blur）時才顯示錯誤，輸入過程中只顯示通過的驗證（例如勾號表示格式正確）。&lt;/p>
&lt;h3 id="送出時驗證on-submit">送出時驗證（on submit）&lt;/h3>
&lt;p>使用者按送出按鈕時統一驗證所有欄位。適合需要多欄位交叉驗證的場景（密碼確認、日期範圍）。&lt;/p>
&lt;p>送出時驗證的風險是使用者填完整張表單才知道哪裡有問題。在欄位多的表單中，使用者需要回頭找到錯誤欄位修正 — 用 scroll 定位和欄位 highlight 減輕這個成本。&lt;/p>
&lt;h3 id="混合策略">混合策略&lt;/h3>
&lt;p>格式驗證用即時（on blur）、業務邏輯驗證用送出時。Email 格式在失去焦點時檢查，「email 是否已被註冊」在送出時呼叫 API 檢查。&lt;/p>
&lt;h2 id="auto-fill-支援">Auto-fill 支援&lt;/h2>
&lt;p>手機系統（iOS AutoFill、Android Autofill）可以自動填入使用者已儲存的資訊（姓名、地址、信用卡）。App 需要正確標記每個欄位的語意類型（&lt;code>autofillHints&lt;/code>），系統才能匹配正確的儲存值。&lt;/p>
&lt;p>正確標記的欄位讓使用者一鍵填入而非手動輸入 — 在 mobile 上減少的打字量直接轉化為轉換率提升。&lt;/p>
&lt;h2 id="錯誤回饋">錯誤回饋&lt;/h2>
&lt;p>錯誤訊息的位置和內容影響使用者修正錯誤的效率。&lt;/p>
&lt;p>&lt;strong>位置&lt;/strong>：錯誤訊息應該緊跟在對應欄位下方，而非集中在表單頂部或底部。使用者需要在看到錯誤的同時看到對應的欄位，不需要來回比對。&lt;/p>
&lt;p>&lt;strong>內容&lt;/strong>：錯誤訊息應該說明「期望什麼」而非「哪裡錯了」。「請輸入有效的 email 地址」比「email 格式無效」提供更多行動指引。&lt;/p>
&lt;p>&lt;strong>視覺&lt;/strong>：錯誤欄位的邊框變色（通常紅色）讓使用者在視覺掃描時快速定位。搭配錯誤文字使用，不要只靠顏色（色盲使用者）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>搜尋場景的輸入設計 → &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/search-ux-pattern/" data-link-title="搜尋 UX 模式" data-link-desc="Debounce / instant / suggestion 三種搜尋模式的取捨 — 和輸入機制的 submit model 維度直接相關">搜尋 UX 模式&lt;/a>&lt;/li>
&lt;li>四維度決策表總覽 → &lt;a href="https://tarrragon.github.io/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">輸入機制決策表&lt;/a>&lt;/li>
&lt;li>安全敏感欄位的 IME 控制 → &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">IME 安全 checklist&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>表單輸入和 terminal 輸入用同一套四維度決策框架（keyboard type / submit model / IME policy / special keys），但每個維度的選項和取捨方向不同。表單場景的使用者輸入的是結構化但自然語言為主的內容 — 姓名、email、地址 — 手機鍵盤的自動行為在這個場景中大部分是幫助。</p>
<h2 id="keyboard-type-的選擇">Keyboard type 的選擇</h2>
<p>表單的每個欄位應該使用最適合該欄位內容的鍵盤。正確的 keyboard type 減少使用者在鍵盤上找按鍵的時間，也讓自動填入和驗證更準確。</p>
<table>
  <thead>
      <tr>
          <th>欄位</th>
          <th>Keyboard type</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Email</td>
          <td>emailAddress</td>
          <td>有 <code>@</code> 和 <code>.</code> 快捷鍵</td>
      </tr>
      <tr>
          <td>電話號碼</td>
          <td>phone</td>
          <td>只顯示數字和 <code>+</code> <code>-</code></td>
      </tr>
      <tr>
          <td>URL</td>
          <td>url</td>
          <td>有 <code>.com</code> 快捷鍵和 <code>/</code> 鍵</td>
      </tr>
      <tr>
          <td>密碼</td>
          <td>visiblePassword</td>
          <td>關閉自動校正，保留字元可見控制</td>
      </tr>
      <tr>
          <td>搜尋</td>
          <td>text</td>
          <td>一般文字，可搭配 <code>textInputAction: search</code></td>
      </tr>
      <tr>
          <td>數字金額</td>
          <td>numberWithOptions(decimal: true)</td>
          <td>數字鍵盤加小數點</td>
      </tr>
  </tbody>
</table>
<h2 id="驗證時機">驗證時機</h2>
<p>表單驗證的時機影響使用者的操作流暢度和錯誤修正效率。</p>
<h3 id="即時驗證on-change">即時驗證（on change）</h3>
<p>每次輸入變化時驗證。適合格式明確的欄位（email 格式、手機號碼長度）。即時驗證在使用者輸入過程中就能回饋格式錯誤，不需要等到送出。</p>
<p>即時驗證的風險是過早報錯。使用者正在輸入 email 地址 <code>user@</code> 時，缺少 domain 部分 — 這個時候報「email 格式錯誤」對使用者沒有幫助。解法是在欄位失去焦點（on blur）時才顯示錯誤，輸入過程中只顯示通過的驗證（例如勾號表示格式正確）。</p>
<h3 id="送出時驗證on-submit">送出時驗證（on submit）</h3>
<p>使用者按送出按鈕時統一驗證所有欄位。適合需要多欄位交叉驗證的場景（密碼確認、日期範圍）。</p>
<p>送出時驗證的風險是使用者填完整張表單才知道哪裡有問題。在欄位多的表單中，使用者需要回頭找到錯誤欄位修正 — 用 scroll 定位和欄位 highlight 減輕這個成本。</p>
<h3 id="混合策略">混合策略</h3>
<p>格式驗證用即時（on blur）、業務邏輯驗證用送出時。Email 格式在失去焦點時檢查，「email 是否已被註冊」在送出時呼叫 API 檢查。</p>
<h2 id="auto-fill-支援">Auto-fill 支援</h2>
<p>手機系統（iOS AutoFill、Android Autofill）可以自動填入使用者已儲存的資訊（姓名、地址、信用卡）。App 需要正確標記每個欄位的語意類型（<code>autofillHints</code>），系統才能匹配正確的儲存值。</p>
<p>正確標記的欄位讓使用者一鍵填入而非手動輸入 — 在 mobile 上減少的打字量直接轉化為轉換率提升。</p>
<h2 id="錯誤回饋">錯誤回饋</h2>
<p>錯誤訊息的位置和內容影響使用者修正錯誤的效率。</p>
<p><strong>位置</strong>：錯誤訊息應該緊跟在對應欄位下方，而非集中在表單頂部或底部。使用者需要在看到錯誤的同時看到對應的欄位，不需要來回比對。</p>
<p><strong>內容</strong>：錯誤訊息應該說明「期望什麼」而非「哪裡錯了」。「請輸入有效的 email 地址」比「email 格式無效」提供更多行動指引。</p>
<p><strong>視覺</strong>：錯誤欄位的邊框變色（通常紅色）讓使用者在視覺掃描時快速定位。搭配錯誤文字使用，不要只靠顏色（色盲使用者）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>搜尋場景的輸入設計 → <a href="/blog/ux-design/03-input-mechanism/search-ux-pattern/" data-link-title="搜尋 UX 模式" data-link-desc="Debounce / instant / suggestion 三種搜尋模式的取捨 — 和輸入機制的 submit model 維度直接相關">搜尋 UX 模式</a></li>
<li>四維度決策表總覽 → <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></li>
<li>安全敏感欄位的 IME 控制 → <a href="/blog/ux-design/03-input-mechanism/ime-security-checklist/" data-link-title="安全敏感輸入框的 IME 控制 checklist" data-link-desc="處理密碼、API key、伺服器路徑等 secret 的輸入框需要關閉 IME 的個人化學習和自動校正 — 安全要求而非 UX 偏好">IME 安全 checklist</a></li>
</ul>
]]></content:encoded></item><item><title>路由可達性檢查</title><link>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/</guid><description>&lt;p>路由可達性檢查比較兩個集合：router 定義的所有路由，和使用者從 UI 操作能到達的所有路由。兩個集合的差集就是問題所在 — 定義了但不可達的路由是入口缺失，可達但未定義的路由是 404 風險。&lt;/p>
&lt;h2 id="定義-vs-可達">定義 vs 可達&lt;/h2>
&lt;h3 id="router-定義的路由">Router 定義的路由&lt;/h3>
&lt;p>現代前端框架（Flutter GoRouter、React Router、Vue Router）通常有一個集中的路由定義檔，列出所有可存取的路徑和對應的畫面元件。這個列表是 router 認知的「所有畫面」。&lt;/p>
&lt;h3 id="ui-可達的路由">UI 可達的路由&lt;/h3>
&lt;p>從首頁（或 app 的入口畫面）開始，透過 UI 上的按鈕、連結、手勢能到達的所有路由。這個集合代表使用者實際能存取的畫面。&lt;/p>
&lt;h3 id="差集分析">差集分析&lt;/h3>
&lt;p>&lt;strong>router 有但 UI 不可達&lt;/strong>：路由定義了、畫面元件也實作了，但沒有任何 UI 元素導航到這個路由。功能存在但使用者找不到入口。&lt;/p>
&lt;p>&lt;strong>UI 指向但 router 沒有&lt;/strong>：UI 上有一個按鈕 &lt;code>navigateTo('/settings')&lt;/code>，但 router 沒有定義 &lt;code>/settings&lt;/code> 路由。使用者點擊後會看到 404 或空白畫面。&lt;/p>
&lt;h2 id="路由存在但不可達的案例">路由存在但不可達的案例&lt;/h2>
&lt;p>app_tunnel 的 router 定義了三條路由：&lt;code>/&lt;/code>（首頁）、&lt;code>/enrollment&lt;/code>（配對）、&lt;code>/terminal&lt;/code>（終端機）。首頁只有一個 Connect Terminal 按鈕導航到 &lt;code>/terminal&lt;/code>。&lt;code>/enrollment&lt;/code> 路由存在，&lt;code>EnrollmentScreen&lt;/code> 完整實作，但首頁沒有任何 UI 元素導航到這個路由（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>）。&lt;/p>
&lt;p>從使用者視角看，配對功能不存在。從開發者視角看，配對功能完整 — 路由定義了、畫面寫好了、業務邏輯都通了。問題出在「入口」這個連接層。&lt;/p>
&lt;p>這和程式碼裡寫了一個 function 但沒有任何地方呼叫它的情況結構相同。Function 本身可能正確無誤，但從系統角度看是死程式碼。路由可達性檢查是這個問題在 UX 層的對應。&lt;/p>
&lt;h2 id="檢查方法">檢查方法&lt;/h2>
&lt;h3 id="手動檢查">手動檢查&lt;/h3>
&lt;p>列出 router 定義的所有路由，然後逐一在 UI 上找到通往該路由的操作路徑。找不到路徑的就是不可達路由。&lt;/p>
&lt;p>手動檢查的成本隨畫面數量線性增長。5 個路由的 app 很快能查完；50 個路由的 app 需要系統化方法。&lt;/p>
&lt;h3 id="從操作盤點交叉比對">從操作盤點交叉比對&lt;/h3>
&lt;p>BDD 操作盤點列出了所有使用者操作（UC）。每個 UC 對應至少一個畫面。把 UC 清單和 router 定義對照：&lt;/p>
&lt;ul>
&lt;li>每個 UC 的主要入口畫面是否有從首頁可達的路徑？&lt;/li>
&lt;li>每個 UC 涉及的中間畫面是否都有進入和退出路徑？&lt;/li>
&lt;/ul>
&lt;p>app_tunnel 的操作盤點列了四個操作（配對、連線、輪替、啟停），首頁只提供了「連線」的入口。「配對」是 app 操作，應該有入口但沒有。「輪替」和「啟停」是主機端操作，不需要 app 入口。這個交叉比對能在 5 分鐘內揭露入口缺失。&lt;/p>
&lt;h3 id="自動化檢查">自動化檢查&lt;/h3>
&lt;p>從 router 定義檔解析所有路由路徑，再從 UI 元件的程式碼中搜尋所有 &lt;code>navigateTo&lt;/code>、&lt;code>context.go&lt;/code>、&lt;code>context.push&lt;/code>、&lt;code>router.push&lt;/code> 等導航呼叫的目標路徑。兩個集合取差集。&lt;/p>
&lt;p>自動化檢查能發現靜態定義的入口缺失，但無法發現動態導航（根據執行期條件決定目標路由）的可達性問題。&lt;/p>
&lt;h2 id="go-vs-push-的語意影響">&lt;code>go&lt;/code> vs &lt;code>push&lt;/code> 的語意影響&lt;/h2>
&lt;p>路由可達性確認之後，導航方式的選擇影響使用者的返回路徑。&lt;/p>
&lt;p>&lt;code>push&lt;/code> 把新畫面推入導航堆疊，使用者按 back 能回到前一個畫面。&lt;code>go&lt;/code> 替換整個導航堆疊，使用者按 back 不會回到原來的畫面。&lt;/p>
&lt;p>選擇 &lt;code>go&lt;/code> 還是 &lt;code>push&lt;/code> 取決於使用者的心理模型：這個導航是「暫時離開主畫面去做一件事，做完回來」（push），還是「切換到另一個主要工作區」（go）。&lt;/p>
&lt;p>app_tunnel 修復時選擇 &lt;code>context.push('/enrollment')&lt;/code> 讓使用者配對完成後按 back 回首頁 — 配對是「暫時去做一件事」，不是切換工作區（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>畫面狀態矩陣完整定義 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法&lt;/a>&lt;/li>
&lt;li>想測試導航路徑的正確性 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 UI 自動化&lt;/a>&lt;/li>
&lt;li>想設計完整導航模式 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>路由可達性檢查比較兩個集合：router 定義的所有路由，和使用者從 UI 操作能到達的所有路由。兩個集合的差集就是問題所在 — 定義了但不可達的路由是入口缺失，可達但未定義的路由是 404 風險。</p>
<h2 id="定義-vs-可達">定義 vs 可達</h2>
<h3 id="router-定義的路由">Router 定義的路由</h3>
<p>現代前端框架（Flutter GoRouter、React Router、Vue Router）通常有一個集中的路由定義檔，列出所有可存取的路徑和對應的畫面元件。這個列表是 router 認知的「所有畫面」。</p>
<h3 id="ui-可達的路由">UI 可達的路由</h3>
<p>從首頁（或 app 的入口畫面）開始，透過 UI 上的按鈕、連結、手勢能到達的所有路由。這個集合代表使用者實際能存取的畫面。</p>
<h3 id="差集分析">差集分析</h3>
<p><strong>router 有但 UI 不可達</strong>：路由定義了、畫面元件也實作了，但沒有任何 UI 元素導航到這個路由。功能存在但使用者找不到入口。</p>
<p><strong>UI 指向但 router 沒有</strong>：UI 上有一個按鈕 <code>navigateTo('/settings')</code>，但 router 沒有定義 <code>/settings</code> 路由。使用者點擊後會看到 404 或空白畫面。</p>
<h2 id="路由存在但不可達的案例">路由存在但不可達的案例</h2>
<p>app_tunnel 的 router 定義了三條路由：<code>/</code>（首頁）、<code>/enrollment</code>（配對）、<code>/terminal</code>（終端機）。首頁只有一個 Connect Terminal 按鈕導航到 <code>/terminal</code>。<code>/enrollment</code> 路由存在，<code>EnrollmentScreen</code> 完整實作，但首頁沒有任何 UI 元素導航到這個路由（<a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a>）。</p>
<p>從使用者視角看，配對功能不存在。從開發者視角看，配對功能完整 — 路由定義了、畫面寫好了、業務邏輯都通了。問題出在「入口」這個連接層。</p>
<p>這和程式碼裡寫了一個 function 但沒有任何地方呼叫它的情況結構相同。Function 本身可能正確無誤，但從系統角度看是死程式碼。路由可達性檢查是這個問題在 UX 層的對應。</p>
<h2 id="檢查方法">檢查方法</h2>
<h3 id="手動檢查">手動檢查</h3>
<p>列出 router 定義的所有路由，然後逐一在 UI 上找到通往該路由的操作路徑。找不到路徑的就是不可達路由。</p>
<p>手動檢查的成本隨畫面數量線性增長。5 個路由的 app 很快能查完；50 個路由的 app 需要系統化方法。</p>
<h3 id="從操作盤點交叉比對">從操作盤點交叉比對</h3>
<p>BDD 操作盤點列出了所有使用者操作（UC）。每個 UC 對應至少一個畫面。把 UC 清單和 router 定義對照：</p>
<ul>
<li>每個 UC 的主要入口畫面是否有從首頁可達的路徑？</li>
<li>每個 UC 涉及的中間畫面是否都有進入和退出路徑？</li>
</ul>
<p>app_tunnel 的操作盤點列了四個操作（配對、連線、輪替、啟停），首頁只提供了「連線」的入口。「配對」是 app 操作，應該有入口但沒有。「輪替」和「啟停」是主機端操作，不需要 app 入口。這個交叉比對能在 5 分鐘內揭露入口缺失。</p>
<h3 id="自動化檢查">自動化檢查</h3>
<p>從 router 定義檔解析所有路由路徑，再從 UI 元件的程式碼中搜尋所有 <code>navigateTo</code>、<code>context.go</code>、<code>context.push</code>、<code>router.push</code> 等導航呼叫的目標路徑。兩個集合取差集。</p>
<p>自動化檢查能發現靜態定義的入口缺失，但無法發現動態導航（根據執行期條件決定目標路由）的可達性問題。</p>
<h2 id="go-vs-push-的語意影響"><code>go</code> vs <code>push</code> 的語意影響</h2>
<p>路由可達性確認之後，導航方式的選擇影響使用者的返回路徑。</p>
<p><code>push</code> 把新畫面推入導航堆疊，使用者按 back 能回到前一個畫面。<code>go</code> 替換整個導航堆疊，使用者按 back 不會回到原來的畫面。</p>
<p>選擇 <code>go</code> 還是 <code>push</code> 取決於使用者的心理模型：這個導航是「暫時離開主畫面去做一件事，做完回來」（push），還是「切換到另一個主要工作區」（go）。</p>
<p>app_tunnel 修復時選擇 <code>context.push('/enrollment')</code> 讓使用者配對完成後按 back 回首頁 — 配對是「暫時去做一件事」，不是切換工作區（<a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a>）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>畫面狀態矩陣完整定義 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</a></li>
<li>想測試導航路徑的正確性 → <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 UI 自動化</a></li>
<li>想設計完整導航模式 → <a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式</a></li>
</ul>
]]></content:encoded></item><item><title>網路斷線 UX 模式</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/network-offline-ux/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/network-offline-ux/</guid><description>&lt;p>網路 &lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate&lt;/a> 和其他 gate 的差異在於狀態的連續性。生物辨識是二元結果（通過或不通過），網路狀態是連續的 — 連線中、已連線、斷線、重新連線、連線但延遲高、連線但頻繁斷開。處理策略取決於功能對即時連線的依賴程度。&lt;/p>
&lt;h2 id="三種處理策略">三種處理策略&lt;/h2>
&lt;h3 id="offline-first">Offline-first&lt;/h3>
&lt;p>功能的核心操作在本地完成，網路用於同步。斷線時使用者仍可操作，重新連線後自動同步差異。&lt;/p>
&lt;p>Offline-first 適合的前提是資料可以本地存儲且衝突可以解決。筆記 app、待辦事項、表單填寫 — 使用者的操作產生本地資料，網路只負責把資料同步到 server。&lt;/p>
&lt;p>Offline-first 的 UX 設計重點是讓使用者知道同步狀態：已同步、待同步、同步失敗。不需要 gate — 網路狀態不阻擋使用者操作。&lt;/p>
&lt;h3 id="retry-with-feedback">Retry with feedback&lt;/h3>
&lt;p>功能需要網路但可以等待。斷線時顯示狀態和重試選項，使用者決定要等還是離開。&lt;/p>
&lt;p>app_tunnel 的 terminal 連線屬於這個模式。WebSocket 連線需要網路，斷線時使用者無法操作終端機。error 和 disconnected 狀態提供重連按鈕讓使用者手動重試。&lt;/p>
&lt;p>Retry 策略的 UX 設計重點：&lt;/p>
&lt;ul>
&lt;li>告知使用者發生什麼（「連線中斷」而非空白畫面）&lt;/li>
&lt;li>提供手動重試（重連按鈕）&lt;/li>
&lt;li>提供退出路徑（返回首頁 — app_tunnel 原本缺少這個）&lt;/li>
&lt;li>自動重試要有上限和間隔遞增（避免無限重試消耗電量）&lt;/li>
&lt;/ul>
&lt;h3 id="degraded-mode">Degraded mode&lt;/h3>
&lt;p>功能部分依賴網路。核心功能離線可用，進階功能需要網路。斷線時自動切換到降級模式，不阻擋使用者操作但功能受限。&lt;/p>
&lt;p>降級模式的 UX 設計重點是清楚標示哪些功能可用、哪些不可用。「離線模式 — 搜尋功能暫時不可用」比靜默隱藏搜尋按鈕更透明。&lt;/p>
&lt;h2 id="網路狀態的-ui-呈現">網路狀態的 UI 呈現&lt;/h2>
&lt;h3 id="全域指示器">全域指示器&lt;/h3>
&lt;p>在 app 頂部或狀態列顯示「離線」標示。適合網路狀態影響全域功能的 app。&lt;/p>
&lt;h3 id="功能級指示器">功能級指示器&lt;/h3>
&lt;p>在需要網路的功能旁邊顯示不可用狀態。適合只有部分功能依賴網路的 app。&lt;/p>
&lt;h3 id="非侵入式通知">非侵入式通知&lt;/h3>
&lt;p>用 Snackbar 或 Toast 短暫顯示「已恢復連線」或「網路中斷」。適合網路狀態偶爾變化的場景。不適合頻繁斷開重連的場景（通知太多會干擾使用者）。&lt;/p>
&lt;h2 id="連線但品質差的場景">連線但品質差的場景&lt;/h2>
&lt;p>網路存在但延遲高或頻繁斷開，比完全離線更難處理。完全離線時 app 可以立即切換到離線模式；連線不穩定時，每次請求可能成功也可能逾時，使用者體驗是「有時候行有時候不行」。&lt;/p>
&lt;p>處理策略：&lt;/p>
&lt;ul>
&lt;li>設定合理的逾時時間（太短會把慢回應判定為失敗，太長讓使用者等太久）&lt;/li>
&lt;li>逾時後顯示狀態和重試選項，不自動重試（避免在不穩定網路上累積重試）&lt;/li>
&lt;li>在 loading 狀態提供取消選項，讓使用者可以中斷等待&lt;/li>
&lt;/ul>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Gate 設計的通用方法論 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>權限請求的 UX 設計 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/permission-request-timing/" data-link-title="Permission 請求時機與措辭" data-link-desc="系統權限請求的時機選擇（首次開啟 vs 功能使用時）和說明文字的設計 — 使用者只有一次機會理解為什麼需要這個權限">Permission 請求時機與措辭&lt;/a>&lt;/li>
&lt;li>畫面狀態矩陣中的網路狀態 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機&lt;/a>&lt;/li>
&lt;li>Server 端背壓如何影響 client UX → &lt;a href="https://tarrragon.github.io/blog/devops/03-traffic-management/backpressure/" data-link-title="背壓機制" data-link-desc="下游處理慢時上游怎麼減速 — 有限 buffer &amp;#43; 回壓訊號的設計、和 rate limit 的區別">DevOps 背壓機制&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>網路 <a href="/blog/ux-design/knowledge-cards/gate/" data-link-title="Gate（UX）" data-link-desc="說明使用者操作流程中「必須通過才能繼續」的關卡，以及成功/失敗/不確定三條路徑的設計責任">gate</a> 和其他 gate 的差異在於狀態的連續性。生物辨識是二元結果（通過或不通過），網路狀態是連續的 — 連線中、已連線、斷線、重新連線、連線但延遲高、連線但頻繁斷開。處理策略取決於功能對即時連線的依賴程度。</p>
<h2 id="三種處理策略">三種處理策略</h2>
<h3 id="offline-first">Offline-first</h3>
<p>功能的核心操作在本地完成，網路用於同步。斷線時使用者仍可操作，重新連線後自動同步差異。</p>
<p>Offline-first 適合的前提是資料可以本地存儲且衝突可以解決。筆記 app、待辦事項、表單填寫 — 使用者的操作產生本地資料，網路只負責把資料同步到 server。</p>
<p>Offline-first 的 UX 設計重點是讓使用者知道同步狀態：已同步、待同步、同步失敗。不需要 gate — 網路狀態不阻擋使用者操作。</p>
<h3 id="retry-with-feedback">Retry with feedback</h3>
<p>功能需要網路但可以等待。斷線時顯示狀態和重試選項，使用者決定要等還是離開。</p>
<p>app_tunnel 的 terminal 連線屬於這個模式。WebSocket 連線需要網路，斷線時使用者無法操作終端機。error 和 disconnected 狀態提供重連按鈕讓使用者手動重試。</p>
<p>Retry 策略的 UX 設計重點：</p>
<ul>
<li>告知使用者發生什麼（「連線中斷」而非空白畫面）</li>
<li>提供手動重試（重連按鈕）</li>
<li>提供退出路徑（返回首頁 — app_tunnel 原本缺少這個）</li>
<li>自動重試要有上限和間隔遞增（避免無限重試消耗電量）</li>
</ul>
<h3 id="degraded-mode">Degraded mode</h3>
<p>功能部分依賴網路。核心功能離線可用，進階功能需要網路。斷線時自動切換到降級模式，不阻擋使用者操作但功能受限。</p>
<p>降級模式的 UX 設計重點是清楚標示哪些功能可用、哪些不可用。「離線模式 — 搜尋功能暫時不可用」比靜默隱藏搜尋按鈕更透明。</p>
<h2 id="網路狀態的-ui-呈現">網路狀態的 UI 呈現</h2>
<h3 id="全域指示器">全域指示器</h3>
<p>在 app 頂部或狀態列顯示「離線」標示。適合網路狀態影響全域功能的 app。</p>
<h3 id="功能級指示器">功能級指示器</h3>
<p>在需要網路的功能旁邊顯示不可用狀態。適合只有部分功能依賴網路的 app。</p>
<h3 id="非侵入式通知">非侵入式通知</h3>
<p>用 Snackbar 或 Toast 短暫顯示「已恢復連線」或「網路中斷」。適合網路狀態偶爾變化的場景。不適合頻繁斷開重連的場景（通知太多會干擾使用者）。</p>
<h2 id="連線但品質差的場景">連線但品質差的場景</h2>
<p>網路存在但延遲高或頻繁斷開，比完全離線更難處理。完全離線時 app 可以立即切換到離線模式；連線不穩定時，每次請求可能成功也可能逾時，使用者體驗是「有時候行有時候不行」。</p>
<p>處理策略：</p>
<ul>
<li>設定合理的逾時時間（太短會把慢回應判定為失敗，太長讓使用者等太久）</li>
<li>逾時後顯示狀態和重試選項，不自動重試（避免在不穩定網路上累積重試）</li>
<li>在 loading 狀態提供取消選項，讓使用者可以中斷等待</li>
</ul>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Gate 設計的通用方法論 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>權限請求的 UX 設計 → <a href="/blog/ux-design/02-gate-fallback/permission-request-timing/" data-link-title="Permission 請求時機與措辭" data-link-desc="系統權限請求的時機選擇（首次開啟 vs 功能使用時）和說明文字的設計 — 使用者只有一次機會理解為什麼需要這個權限">Permission 請求時機與措辭</a></li>
<li>畫面狀態矩陣中的網路狀態 → <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a></li>
<li>Server 端背壓如何影響 client UX → <a href="/blog/devops/03-traffic-management/backpressure/" data-link-title="背壓機制" data-link-desc="下游處理慢時上游怎麼減速 — 有限 buffer &#43; 回壓訊號的設計、和 rate limit 的區別">DevOps 背壓機制</a></li>
</ul>
]]></content:encoded></item><item><title>模組三：輸入機制設計</title><link>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/</guid><description>&lt;p>回答「使用者怎麼輸入資料」。手機鍵盤和桌面鍵盤的差異比想像的大。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UF-6&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a>&lt;/td>
 &lt;td>6 個 TextField 參數全是事後 hotfix — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UF-7&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a>&lt;/td>
 &lt;td>enableIMEPersonalizedLearning 有安全意涵（secret → IME 詞庫）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>UF-8&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a>&lt;/td>
 &lt;td>整行送出 vs 逐字元影響 protocol 設計 — &lt;strong>SSoT 主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 輸入機制四維度決策表（keyboard type / submit model / IME policy / special keys）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Terminal app 輸入設計（CLI 特殊需求）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 表單 UX 模式（validate / auto-fill / error feedback）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 搜尋 UX 模式（debounce / instant / suggestion）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 安全敏感輸入框的 IME 控制 checklist&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試&lt;/a>：整行 vs 逐字元影響 protocol test 斷言&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安&lt;/a>：IME 個人化學習 = secret 洩漏風險&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「使用者怎麼輸入資料」。手機鍵盤和桌面鍵盤的差異比想像的大。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UF-6</td>
          <td><a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a></td>
          <td>6 個 TextField 參數全是事後 hotfix — <strong>本模組主寫</strong></td>
      </tr>
      <tr>
          <td>UF-7</td>
          <td><a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a></td>
          <td>enableIMEPersonalizedLearning 有安全意涵（secret → IME 詞庫）</td>
      </tr>
      <tr>
          <td>UF-8</td>
          <td><a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a></td>
          <td>整行送出 vs 逐字元影響 protocol 設計 — <strong>SSoT 主寫</strong></td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 輸入機制四維度決策表（keyboard type / submit model / IME policy / special keys）</li>
<li><input checked="" disabled="" type="checkbox"> Terminal app 輸入設計（CLI 特殊需求）</li>
<li><input checked="" disabled="" type="checkbox"> 表單 UX 模式（validate / auto-fill / error feedback）</li>
<li><input checked="" disabled="" type="checkbox"> 搜尋 UX 模式（debounce / instant / suggestion）</li>
<li><input checked="" disabled="" type="checkbox"> 安全敏感輸入框的 IME 控制 checklist</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>→ <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">testing 模組三 協議整合測試</a>：整行 vs 逐字元影響 protocol test 斷言</li>
<li>→ <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a>：IME 個人化學習 = secret 洩漏風險</li>
</ul>
]]></content:encoded></item><item><title>Deep link 設計</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/deep-link-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/deep-link-design/</guid><description>&lt;p>Deep link 讓 app 外部的來源（網頁連結、推播通知、其他 app）直接導航到 app 的特定畫面，而非每次都從首頁開始。Deep link 的設計需要考慮三個問題：URL 結構如何對應到畫面、app 未安裝時怎麼處理、導航堆疊如何重建。&lt;/p>
&lt;h2 id="三種-deep-link-機制">三種 deep link 機制&lt;/h2>
&lt;h3 id="custom-url-scheme">Custom URL scheme&lt;/h3>
&lt;p>App 註冊自訂的 URL scheme（&lt;code>myapp://&lt;/code>），系統收到這個 scheme 的 URL 時打開 app。&lt;code>myapp://terminal?host=192.168.1.100&lt;/code> 打開 app 的 terminal 畫面。&lt;/p>
&lt;p>Custom URL scheme 的限制：沒有 ownership 驗證（任何 app 都可以註冊 &lt;code>myapp://&lt;/code>），只在 app 已安裝時有效（未安裝時 URL 無效），不適合 web 分享（瀏覽器無法開啟 &lt;code>myapp://&lt;/code>）。&lt;/p>
&lt;h3 id="universal-linkios-app-linkandroid">Universal Link（iOS）/ App Link（Android）&lt;/h3>
&lt;p>App 宣告擁有特定 domain 的 URL（&lt;code>https://example.com/terminal&lt;/code>）。系統驗證 domain 的 ownership（domain 上放 &lt;code>.well-known/apple-app-site-association&lt;/code> 或 &lt;code>assetlinks.json&lt;/code>），驗證通過後這些 URL 直接在 app 中打開。&lt;/p>
&lt;p>優勢：使用標準 HTTPS URL（可以在瀏覽器中分享）、有 ownership 驗證（防止冒充）、app 未安裝時 fallback 到網頁。&lt;/p>
&lt;h3 id="deferred-deep-link">Deferred deep link&lt;/h3>
&lt;p>使用者點擊 deep link 時 app 未安裝。系統引導使用者到 app store 安裝，安裝後首次開啟時自動導航到 deep link 指定的畫面。&lt;/p>
&lt;p>Deferred deep link 需要第三方服務（Firebase Dynamic Links、Branch）或自建機制在安裝前後傳遞 URL 參數。&lt;/p>
&lt;h2 id="url-結構設計">URL 結構設計&lt;/h2>
&lt;p>Deep link 的 URL 結構應該和 GoRouter 的路由定義一致。GoRouter 原生支援 deep link — URL path 就是路由 path。&lt;/p>





&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">https://example.com/terminal → TerminalScreen
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">https://example.com/enrollment → EnrollmentScreen
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">https://example.com/terminal?host=x → TerminalScreen(host: x)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>URL 參數（query parameters）傳遞畫面需要的資料。參數值避免包含敏感資訊 — URL 可能被系統日誌、分析工具、中間人記錄。&lt;/p>
&lt;h2 id="導航堆疊重建">導航堆疊重建&lt;/h2>
&lt;p>使用者從 deep link 直接進入 &lt;code>/terminal&lt;/code> 畫面時，導航堆疊中沒有首頁。使用者按 back 應該回到首頁還是離開 app？&lt;/p>
&lt;h3 id="重建完整堆疊">重建完整堆疊&lt;/h3>
&lt;p>GoRouter 的 &lt;code>go('/terminal')&lt;/code> 可以設定為自動把前置路由放入堆疊。使用者按 back 回到首頁，再按 back 離開 app。使用者的心理模型是「deep link 帶我到這個畫面，back 帶我到 app 的正常入口」。&lt;/p>
&lt;h3 id="只放-deep-link-目標">只放 deep link 目標&lt;/h3>
&lt;p>堆疊中只有 deep link 目標畫面。按 back 離開 app。適合「一次性操作」的 deep link（打開 → 操作 → 離開）。&lt;/p>
&lt;h3 id="選擇策略">選擇策略&lt;/h3>
&lt;p>如果 deep link 的畫面是 app 日常使用的一部分，重建完整堆疊讓使用者能繼續在 app 中操作。如果 deep link 是從外部觸發的獨立操作（掃描 QR code → 顯示結果），只放目標畫面更簡潔。&lt;/p>
&lt;h2 id="deep-link-測試">Deep link 測試&lt;/h2>
&lt;p>Deep link 需要端對端測試 — 從外部觸發 URL，驗證 app 導航到正確畫面。&lt;/p></description><content:encoded><![CDATA[<p>Deep link 讓 app 外部的來源（網頁連結、推播通知、其他 app）直接導航到 app 的特定畫面，而非每次都從首頁開始。Deep link 的設計需要考慮三個問題：URL 結構如何對應到畫面、app 未安裝時怎麼處理、導航堆疊如何重建。</p>
<h2 id="三種-deep-link-機制">三種 deep link 機制</h2>
<h3 id="custom-url-scheme">Custom URL scheme</h3>
<p>App 註冊自訂的 URL scheme（<code>myapp://</code>），系統收到這個 scheme 的 URL 時打開 app。<code>myapp://terminal?host=192.168.1.100</code> 打開 app 的 terminal 畫面。</p>
<p>Custom URL scheme 的限制：沒有 ownership 驗證（任何 app 都可以註冊 <code>myapp://</code>），只在 app 已安裝時有效（未安裝時 URL 無效），不適合 web 分享（瀏覽器無法開啟 <code>myapp://</code>）。</p>
<h3 id="universal-linkios-app-linkandroid">Universal Link（iOS）/ App Link（Android）</h3>
<p>App 宣告擁有特定 domain 的 URL（<code>https://example.com/terminal</code>）。系統驗證 domain 的 ownership（domain 上放 <code>.well-known/apple-app-site-association</code> 或 <code>assetlinks.json</code>），驗證通過後這些 URL 直接在 app 中打開。</p>
<p>優勢：使用標準 HTTPS URL（可以在瀏覽器中分享）、有 ownership 驗證（防止冒充）、app 未安裝時 fallback 到網頁。</p>
<h3 id="deferred-deep-link">Deferred deep link</h3>
<p>使用者點擊 deep link 時 app 未安裝。系統引導使用者到 app store 安裝，安裝後首次開啟時自動導航到 deep link 指定的畫面。</p>
<p>Deferred deep link 需要第三方服務（Firebase Dynamic Links、Branch）或自建機制在安裝前後傳遞 URL 參數。</p>
<h2 id="url-結構設計">URL 結構設計</h2>
<p>Deep link 的 URL 結構應該和 GoRouter 的路由定義一致。GoRouter 原生支援 deep link — URL path 就是路由 path。</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">https://example.com/terminal        → TerminalScreen
</span></span><span class="line"><span class="ln">2</span><span class="cl">https://example.com/enrollment      → EnrollmentScreen
</span></span><span class="line"><span class="ln">3</span><span class="cl">https://example.com/terminal?host=x → TerminalScreen(host: x)</span></span></code></pre></div><p>URL 參數（query parameters）傳遞畫面需要的資料。參數值避免包含敏感資訊 — URL 可能被系統日誌、分析工具、中間人記錄。</p>
<h2 id="導航堆疊重建">導航堆疊重建</h2>
<p>使用者從 deep link 直接進入 <code>/terminal</code> 畫面時，導航堆疊中沒有首頁。使用者按 back 應該回到首頁還是離開 app？</p>
<h3 id="重建完整堆疊">重建完整堆疊</h3>
<p>GoRouter 的 <code>go('/terminal')</code> 可以設定為自動把前置路由放入堆疊。使用者按 back 回到首頁，再按 back 離開 app。使用者的心理模型是「deep link 帶我到這個畫面，back 帶我到 app 的正常入口」。</p>
<h3 id="只放-deep-link-目標">只放 deep link 目標</h3>
<p>堆疊中只有 deep link 目標畫面。按 back 離開 app。適合「一次性操作」的 deep link（打開 → 操作 → 離開）。</p>
<h3 id="選擇策略">選擇策略</h3>
<p>如果 deep link 的畫面是 app 日常使用的一部分，重建完整堆疊讓使用者能繼續在 app 中操作。如果 deep link 是從外部觸發的獨立操作（掃描 QR code → 顯示結果），只放目標畫面更簡潔。</p>
<h2 id="deep-link-測試">Deep link 測試</h2>
<p>Deep link 需要端對端測試 — 從外部觸發 URL，驗證 app 導航到正確畫面。</p>
<p>測試項目：</p>
<ul>
<li>每個路由的 deep link 能正確打開</li>
<li>URL 參數正確傳遞到畫面</li>
<li>App 在前景、背景、未啟動三種狀態下都能處理 deep link</li>
<li>無效的 deep link URL 有合理的 fallback（導航到首頁或顯示錯誤）</li>
<li>Universal Link 的 domain verification 正確</li>
</ul>
<p>Deep link 的實作在 Flutter 中由 GoRouter 的 route matching 處理 — <a href="/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">Flutter GoRouter 導航設計</a>包含 deep link 的設定方式。Deep link 觸發的導航操作（go vs push）影響使用者的返回路徑，語意差異見 <a href="/blog/ux-design/05-navigation-patterns/go-push-semantics/" data-link-title="go vs push vs pushReplacement 的 UX 語意表" data-link-desc="三種導航方法對堆疊、back 行為、使用者心理模型的影響 — 選擇依據是使用者的意圖而非技術方便">go vs push vs pushReplacement 語意表</a>。Deep link 的端對端驗證在 <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 自動化 UI 驗證</a>中歸類到導航路徑 test。</p>
]]></content:encoded></item><item><title>error → retry → error 循環的逃生口設計</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-loop-escape/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-loop-escape/</guid><description>&lt;p>error → retry → error 循環是指使用者遇到錯誤、點擊重試、再次失敗、再次重試的迴圈。當底層問題持續存在時（伺服器關閉、認證過期、硬體故障），重試永遠不會成功。使用者被困在這個迴圈中，唯一的出路是殺掉 app。&lt;/p>
&lt;h2 id="循環產生的條件">循環產生的條件&lt;/h2>
&lt;p>三個條件同時成立時產生困住使用者的重試循環：&lt;/p>
&lt;p>&lt;strong>錯誤持續存在&lt;/strong>。暫時性錯誤（網路閃斷）會在重試中自行恢復。持續性錯誤（伺服器已關閉）不會因重試而改變。&lt;/p>
&lt;p>&lt;strong>UI 只提供重試選項&lt;/strong>。Error 畫面上唯一的按鈕是「重試」，沒有返回、取消或其他替代路徑。&lt;/p>
&lt;p>&lt;strong>沒有重試次數上限&lt;/strong>。使用者可以無限重試，每次都失敗，每次都回到同一個 error 畫面。&lt;/p>
&lt;p>app_tunnel 修復前的 error 和 disconnected 狀態就是這個模式 — 有重連按鈕但沒有 back 按鈕。重連失敗時使用者只能再次重連，無法返回首頁（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>）。&lt;/p>
&lt;h2 id="逃生口設計">逃生口設計&lt;/h2>
&lt;h3 id="每個-error-畫面至少兩個選項">每個 error 畫面至少兩個選項&lt;/h3>
&lt;p>重試按鈕旁邊放一個退出路徑。「重試」+「返回首頁」是最小組合。使用者想繼續嘗試就重試，想離開就返回。&lt;/p>
&lt;p>這和&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>的退出路徑要求一致 — 每個狀態至少一條退出路徑（&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">定義與填寫方法&lt;/a>）。Error 狀態的退出路徑包括重試（回到 connecting 狀態）和返回（離開當前畫面）。&lt;/p>
&lt;h3 id="自動重試達上限後切換-ui">自動重試達上限後切換 UI&lt;/h3>
&lt;p>自動重試有固定上限（例如 3 次或 5 次）。達到上限後，UI 從「正在重試」切換到「連線失敗」，提供手動重試和退出路徑。&lt;/p>
&lt;p>切換 UI 的意義是向使用者傳達「自動恢復已經嘗試過了，需要你來判斷接下來怎麼做」。使用者可能知道問題的原因（忘了開伺服器、WiFi 沒連上），手動修正後再重試。&lt;/p>
&lt;h3 id="提供問題診斷線索">提供問題診斷線索&lt;/h3>
&lt;p>在 error 畫面提供足夠的資訊讓使用者判斷是否值得繼續重試。「伺服器沒有回應」和「認證已過期」是不同的問題 — 前者可能重試會成功（伺服器正在重啟），後者重試不會改變結果（需要重新登入）。&lt;/p>
&lt;p>診斷資訊幫助使用者做出正確決策：繼續重試、返回重新操作、或完全離開。&lt;/p>
&lt;h3 id="替代操作路徑">替代操作路徑&lt;/h3>
&lt;p>除了重試和返回，某些場景可以提供第三條路。連線特定伺服器失敗時，提供「選擇其他伺服器」的選項。認證失敗時，提供「用其他方式登入」的選項。&lt;/p>
&lt;p>替代路徑把「失敗 → 重試同樣的操作」擴展成「失敗 → 嘗試不同的操作」，增加使用者脫離困境的機會。&lt;/p>
&lt;h2 id="檢查方法">檢查方法&lt;/h2>
&lt;p>用畫面狀態矩陣檢查 error 和 retry 狀態：&lt;/p>
&lt;ol>
&lt;li>找到所有 error 狀態（矩陣中 type = error 的行）&lt;/li>
&lt;li>檢查每個 error 狀態的「可用操作」欄 — 是否除了「重試」之外還有其他操作&lt;/li>
&lt;li>檢查每個 error 狀態的「退出路徑」欄 — 是否有離開當前畫面的路徑&lt;/li>
&lt;li>操作欄只有「重試」且退出路徑為空 = 潛在的 retry loop&lt;/li>
&lt;/ol>
&lt;p>這個檢查在設計階段就能完成，成本遠低於實機測試時才發現使用者被困住。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>錯誤訊息如何引導使用者行動 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/error-message-principles/" data-link-title="錯誤訊息撰寫原則" data-link-desc="錯誤訊息的兩個職責：使用者能讀懂發生什麼、使用者能決定下一步做什麼">錯誤訊息撰寫原則&lt;/a>&lt;/li>
&lt;li>重試策略的選擇 → &lt;a href="https://tarrragon.github.io/blog/ux-design/04-error-recovery/retry-mechanism-ux/" data-link-title="Retry 機制 UX" data-link-desc="自動 vs 手動重試、指數退避 vs 立即重試 — 重試策略的選擇取決於失敗的可恢復性和使用者的等待意願">Retry 機制 UX&lt;/a>&lt;/li>
&lt;li>畫面狀態矩陣的退出路徑檢查 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機&lt;/a>&lt;/li>
&lt;li>Mock 遮蔽 error 場景的問題 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略分層&lt;/a>&lt;/li>
&lt;li>Error 事件的分類與收集 → &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一 監控心智模型&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>error → retry → error 循環是指使用者遇到錯誤、點擊重試、再次失敗、再次重試的迴圈。當底層問題持續存在時（伺服器關閉、認證過期、硬體故障），重試永遠不會成功。使用者被困在這個迴圈中，唯一的出路是殺掉 app。</p>
<h2 id="循環產生的條件">循環產生的條件</h2>
<p>三個條件同時成立時產生困住使用者的重試循環：</p>
<p><strong>錯誤持續存在</strong>。暫時性錯誤（網路閃斷）會在重試中自行恢復。持續性錯誤（伺服器已關閉）不會因重試而改變。</p>
<p><strong>UI 只提供重試選項</strong>。Error 畫面上唯一的按鈕是「重試」，沒有返回、取消或其他替代路徑。</p>
<p><strong>沒有重試次數上限</strong>。使用者可以無限重試，每次都失敗，每次都回到同一個 error 畫面。</p>
<p>app_tunnel 修復前的 error 和 disconnected 狀態就是這個模式 — 有重連按鈕但沒有 back 按鈕。重連失敗時使用者只能再次重連，無法返回首頁（<a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a>）。</p>
<h2 id="逃生口設計">逃生口設計</h2>
<h3 id="每個-error-畫面至少兩個選項">每個 error 畫面至少兩個選項</h3>
<p>重試按鈕旁邊放一個退出路徑。「重試」+「返回首頁」是最小組合。使用者想繼續嘗試就重試，想離開就返回。</p>
<p>這和<a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>的退出路徑要求一致 — 每個狀態至少一條退出路徑（<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">定義與填寫方法</a>）。Error 狀態的退出路徑包括重試（回到 connecting 狀態）和返回（離開當前畫面）。</p>
<h3 id="自動重試達上限後切換-ui">自動重試達上限後切換 UI</h3>
<p>自動重試有固定上限（例如 3 次或 5 次）。達到上限後，UI 從「正在重試」切換到「連線失敗」，提供手動重試和退出路徑。</p>
<p>切換 UI 的意義是向使用者傳達「自動恢復已經嘗試過了，需要你來判斷接下來怎麼做」。使用者可能知道問題的原因（忘了開伺服器、WiFi 沒連上），手動修正後再重試。</p>
<h3 id="提供問題診斷線索">提供問題診斷線索</h3>
<p>在 error 畫面提供足夠的資訊讓使用者判斷是否值得繼續重試。「伺服器沒有回應」和「認證已過期」是不同的問題 — 前者可能重試會成功（伺服器正在重啟），後者重試不會改變結果（需要重新登入）。</p>
<p>診斷資訊幫助使用者做出正確決策：繼續重試、返回重新操作、或完全離開。</p>
<h3 id="替代操作路徑">替代操作路徑</h3>
<p>除了重試和返回，某些場景可以提供第三條路。連線特定伺服器失敗時，提供「選擇其他伺服器」的選項。認證失敗時，提供「用其他方式登入」的選項。</p>
<p>替代路徑把「失敗 → 重試同樣的操作」擴展成「失敗 → 嘗試不同的操作」，增加使用者脫離困境的機會。</p>
<h2 id="檢查方法">檢查方法</h2>
<p>用畫面狀態矩陣檢查 error 和 retry 狀態：</p>
<ol>
<li>找到所有 error 狀態（矩陣中 type = error 的行）</li>
<li>檢查每個 error 狀態的「可用操作」欄 — 是否除了「重試」之外還有其他操作</li>
<li>檢查每個 error 狀態的「退出路徑」欄 — 是否有離開當前畫面的路徑</li>
<li>操作欄只有「重試」且退出路徑為空 = 潛在的 retry loop</li>
</ol>
<p>這個檢查在設計階段就能完成，成本遠低於實機測試時才發現使用者被困住。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>錯誤訊息如何引導使用者行動 → <a href="/blog/ux-design/04-error-recovery/error-message-principles/" data-link-title="錯誤訊息撰寫原則" data-link-desc="錯誤訊息的兩個職責：使用者能讀懂發生什麼、使用者能決定下一步做什麼">錯誤訊息撰寫原則</a></li>
<li>重試策略的選擇 → <a href="/blog/ux-design/04-error-recovery/retry-mechanism-ux/" data-link-title="Retry 機制 UX" data-link-desc="自動 vs 手動重試、指數退避 vs 立即重試 — 重試策略的選擇取決於失敗的可恢復性和使用者的等待意願">Retry 機制 UX</a></li>
<li>畫面狀態矩陣的退出路徑檢查 → <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a></li>
<li>Mock 遮蔽 error 場景的問題 → <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一 測試策略分層</a></li>
<li>Error 事件的分類與收集 → <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一 監控心智模型</a></li>
</ul>
]]></content:encoded></item><item><title>Permission 請求時機與措辭</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/permission-request-timing/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/permission-request-timing/</guid><description>&lt;p>系統權限（相機、位置、通知、麥克風）的請求對話框由作業系統控制，app 只能決定「什麼時候觸發」和「觸發前顯示什麼說明」。使用者拒絕後，再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。這意味著第一次請求的時機和說明內容直接影響授權率。&lt;/p>
&lt;h2 id="請求時機">請求時機&lt;/h2>
&lt;h3 id="首次開啟時一次性請求">首次開啟時一次性請求&lt;/h3>
&lt;p>App 首次啟動時依序請求所有需要的權限。優點是使用者只被打斷一次；缺點是使用者尚未使用任何功能，不理解每個權限的用途，傾向拒絕。&lt;/p>
&lt;p>這個模式適合權限數量少（1-2 個）且和 app 核心功能直接相關的情境。相機 app 在首次開啟時請求相機權限，使用者能直覺理解原因。&lt;/p>
&lt;h3 id="功能使用時即時請求">功能使用時即時請求&lt;/h3>
&lt;p>使用者點擊需要權限的功能時才請求。優點是使用者在操作 context 中，能理解為什麼需要這個權限；缺點是操作流程被打斷。&lt;/p>
&lt;p>這個模式適合權限和特定功能綁定的情境。掃描 QR code 時請求相機權限，使用者正在嘗試掃描，理解為什麼需要相機。&lt;/p>
&lt;h3 id="推薦策略">推薦策略&lt;/h3>
&lt;p>功能使用時即時請求是多數場景的推薦策略。使用者有操作 context，授權率較高。打斷可以透過 pre-permission 說明畫面降低突兀感。&lt;/p>
&lt;h2 id="pre-permission-說明畫面">Pre-permission 說明畫面&lt;/h2>
&lt;p>在觸發系統權限對話框之前，app 先顯示自己的說明畫面，解釋為什麼需要這個權限和用途。&lt;/p>
&lt;p>說明畫面的設計要點：&lt;/p>
&lt;p>&lt;strong>說明用途而非技術細節&lt;/strong>。「需要相機來掃描裝置上的 QR code」比「app 需要存取 AVCaptureDevice」更有用。使用者關心的是「為什麼」，不是「用什麼 API」。&lt;/p>
&lt;p>&lt;strong>提供「稍後再說」選項&lt;/strong>。使用者可能想先了解 app 再決定是否授權。強制授權（沒有跳過選項）會讓使用者選擇拒絕。&lt;/p>
&lt;p>&lt;strong>視覺化說明&lt;/strong>。用截圖或圖示展示「授權後這個功能長什麼樣」，讓使用者預覽授權的價值。&lt;/p>
&lt;h2 id="拒絕後的處理">拒絕後的處理&lt;/h2>
&lt;p>使用者拒絕權限後，app 需要：&lt;/p>
&lt;p>&lt;strong>記住拒絕狀態&lt;/strong>。不要在每次使用者操作同一功能時都顯示 pre-permission 說明（使用者已經表達不想授權，反覆詢問是騷擾）。&lt;/p>
&lt;p>&lt;strong>提供功能降級&lt;/strong>。如果可能，提供不需要權限的替代方案。掃描 QR code 可以改成手動輸入配對碼。&lt;/p>
&lt;p>&lt;strong>在適當時機再提醒&lt;/strong>。使用者多次使用需要權限的功能但都因為沒有權限而失敗時，用非侵入式提示（Snackbar）說明「開啟相機權限可以使用掃描功能」加設定連結。&lt;/p>
&lt;p>&lt;strong>引導到系統設定&lt;/strong>。一旦使用者在系統對話框中選擇「不再詢問」（Android）或拒絕（iOS 拒絕後系統不再彈窗），唯一的路徑是引導使用者到系統設定手動開啟。提供直接跳轉到 app 設定頁面的按鈕。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Gate 設計的通用方法論 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法&lt;/a>&lt;/li>
&lt;li>網路 gate 的處理策略 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/network-offline-ux/" data-link-title="網路斷線 UX 模式" data-link-desc="Offline-first / retry / degraded mode 三種網路 gate 的處理策略 — 取決於功能是否依賴即時連線">網路斷線 UX 模式&lt;/a>&lt;/li>
&lt;li>開發環境遮蔽 gate 問題 → &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>系統權限（相機、位置、通知、麥克風）的請求對話框由作業系統控制，app 只能決定「什麼時候觸發」和「觸發前顯示什麼說明」。使用者拒絕後，再次請求不會彈出系統對話框 — 必須引導使用者到系統設定手動開啟。這意味著第一次請求的時機和說明內容直接影響授權率。</p>
<h2 id="請求時機">請求時機</h2>
<h3 id="首次開啟時一次性請求">首次開啟時一次性請求</h3>
<p>App 首次啟動時依序請求所有需要的權限。優點是使用者只被打斷一次；缺點是使用者尚未使用任何功能，不理解每個權限的用途，傾向拒絕。</p>
<p>這個模式適合權限數量少（1-2 個）且和 app 核心功能直接相關的情境。相機 app 在首次開啟時請求相機權限，使用者能直覺理解原因。</p>
<h3 id="功能使用時即時請求">功能使用時即時請求</h3>
<p>使用者點擊需要權限的功能時才請求。優點是使用者在操作 context 中，能理解為什麼需要這個權限；缺點是操作流程被打斷。</p>
<p>這個模式適合權限和特定功能綁定的情境。掃描 QR code 時請求相機權限，使用者正在嘗試掃描，理解為什麼需要相機。</p>
<h3 id="推薦策略">推薦策略</h3>
<p>功能使用時即時請求是多數場景的推薦策略。使用者有操作 context，授權率較高。打斷可以透過 pre-permission 說明畫面降低突兀感。</p>
<h2 id="pre-permission-說明畫面">Pre-permission 說明畫面</h2>
<p>在觸發系統權限對話框之前，app 先顯示自己的說明畫面，解釋為什麼需要這個權限和用途。</p>
<p>說明畫面的設計要點：</p>
<p><strong>說明用途而非技術細節</strong>。「需要相機來掃描裝置上的 QR code」比「app 需要存取 AVCaptureDevice」更有用。使用者關心的是「為什麼」，不是「用什麼 API」。</p>
<p><strong>提供「稍後再說」選項</strong>。使用者可能想先了解 app 再決定是否授權。強制授權（沒有跳過選項）會讓使用者選擇拒絕。</p>
<p><strong>視覺化說明</strong>。用截圖或圖示展示「授權後這個功能長什麼樣」，讓使用者預覽授權的價值。</p>
<h2 id="拒絕後的處理">拒絕後的處理</h2>
<p>使用者拒絕權限後，app 需要：</p>
<p><strong>記住拒絕狀態</strong>。不要在每次使用者操作同一功能時都顯示 pre-permission 說明（使用者已經表達不想授權，反覆詢問是騷擾）。</p>
<p><strong>提供功能降級</strong>。如果可能，提供不需要權限的替代方案。掃描 QR code 可以改成手動輸入配對碼。</p>
<p><strong>在適當時機再提醒</strong>。使用者多次使用需要權限的功能但都因為沒有權限而失敗時，用非侵入式提示（Snackbar）說明「開啟相機權限可以使用掃描功能」加設定連結。</p>
<p><strong>引導到系統設定</strong>。一旦使用者在系統對話框中選擇「不再詢問」（Android）或拒絕（iOS 拒絕後系統不再彈窗），唯一的路徑是引導使用者到系統設定手動開啟。提供直接跳轉到 app 設定頁面的按鈕。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Gate 設計的通用方法論 → <a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">Gate 分類與三問設計法</a></li>
<li>網路 gate 的處理策略 → <a href="/blog/ux-design/02-gate-fallback/network-offline-ux/" data-link-title="網路斷線 UX 模式" data-link-desc="Offline-first / retry / degraded mode 三種網路 gate 的處理策略 — 取決於功能是否依賴即時連線">網路斷線 UX 模式</a></li>
<li>開發環境遮蔽 gate 問題 → <a href="/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/" data-link-title="開發環境 vs 真機的 gate 行為差異表" data-link-desc="模擬器、debug build、test 環境中的 gate 行為和真機 release build 不同 — 差異表讓開發者在上機前知道哪些 gate 還沒被真實驗證">開發環境 vs 真機的 gate 行為差異表</a></li>
</ul>
]]></content:encoded></item><item><title>U.C4 首頁缺配對入口按鈕、導航流未完整列出</title><link>https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/</guid><description>&lt;p>這個案例的核心責任是說明導航流設計必須覆蓋所有操作情境的入口，不只是最常用的那個。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 首頁在 W2-001 修復前只有一個按鈕：Connect Terminal（對應 UC-02 日常連線）。配對功能（UC-01 首次配對）沒有入口 — &lt;code>EnrollmentScreen&lt;/code> 和 &lt;code>QrScannerScreen&lt;/code> 都存在且可運作，但首頁沒有按鈕導航過去。&lt;/p>
&lt;p>Router 定義了三條路由，全部可存取：&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">GoRouter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">routes:&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="n">HomeScreen&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/enrollment&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="n">EnrollmentScreen&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="n">GoRoute&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nl">path:&lt;/span> &lt;span class="s1">&amp;#39;/terminal&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nl">builder:&lt;/span> &lt;span class="p">...&lt;/span> &lt;span class="n">TerminalScreen&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="p">]);&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>但 HomeScreen 只有一個 &lt;code>context.go('/terminal')&lt;/code> 按鈕，&lt;code>/enrollment&lt;/code> 路由存在但從 UI 無法到達。&lt;/p>
&lt;p>W2-001 修復加入 &lt;code>OutlinedButton.icon&lt;/code> 連結到 &lt;code>/enrollment&lt;/code>，並用 &lt;code>context.push&lt;/code>（非 &lt;code>context.go&lt;/code>）讓配對完成後能返回首頁。&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;code>OutlinedButton&lt;/code> + &lt;code>context.push('/enrollment')&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>根因&lt;/td>
 &lt;td>導航流只設計了「日常連線」入口，遺漏「首次配對」入口&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>操作盤點有四個操作，首頁只有一個入口&lt;/strong>。操作盤點段列出四個操作：配對、連線、輪替、啟停。首頁應該是這四個操作的導航 hub，至少要有「配對」和「連線」兩個入口（輪替和啟停是主機端操作，不需要 app 入口）。只放 Connect Terminal 等於假設「使用者已經配對過」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>路由存在但 UI 不可達 = 死程式碼的 UX 版本&lt;/strong>。&lt;code>/enrollment&lt;/code> 路由在 router 裡定義了，&lt;code>EnrollmentScreen&lt;/code> 也完整實作了，但使用者從 UI 無法觸及。這跟寫了函式但沒有呼叫者一樣 — 功能正確但不可存取。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>&lt;code>go&lt;/code> vs &lt;code>push&lt;/code> 的語意差異影響 UX&lt;/strong>。W2 修復用 &lt;code>context.push('/enrollment')&lt;/code> 而非 &lt;code>context.go('/enrollment')&lt;/code> — &lt;code>push&lt;/code> 保留返回堆疊讓使用者配對後按 back 回首頁；&lt;code>go&lt;/code> 替換整個路由堆疊、沒有 back。這個決策影響使用者的導航體驗，但也是事後才想到的。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>導航流從操作盤點反推&lt;/strong>：每個 UC（用例）的主入口在哪？首頁應該是哪些 UC 的 hub？列出來，確認每個 UC 至少有一條從首頁可達的路徑。&lt;/li>
&lt;li>&lt;strong>路由可達性檢查&lt;/strong>：router 定義的每個路由都應該從 UI 可達。不可達的路由要嘛是遺漏入口（本案例），要嘛是應該刪除的死路由。可以寫一個 lint 檢查。&lt;/li>
&lt;li>&lt;strong>首次 vs 日常使用者的 UX 區分&lt;/strong>：首次使用者需要 onboarding 流程（配對 → 連線），日常使用者只需要連線。兩種入口都要在首頁可見，但可以用視覺層級區分主要/次要。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計完整導航流 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">模組五：導航模式&lt;/a>&lt;/li>
&lt;li>想檢查畫面狀態矩陣的退出路徑 → &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五狀態零退出&lt;/a>&lt;/li>
&lt;li>想做路由可達性自動化檢查 → 待補：Flutter GoRouter 路由可達性 lint&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明導航流設計必須覆蓋所有操作情境的入口，不只是最常用的那個。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 首頁在 W2-001 修復前只有一個按鈕：Connect Terminal（對應 UC-02 日常連線）。配對功能（UC-01 首次配對）沒有入口 — <code>EnrollmentScreen</code> 和 <code>QrScannerScreen</code> 都存在且可運作，但首頁沒有按鈕導航過去。</p>
<p>Router 定義了三條路由，全部可存取：</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">GoRouter</span><span class="p">(</span><span class="nl">routes:</span> <span class="p">[</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...</span> <span class="n">HomeScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/enrollment&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...</span> <span class="n">EnrollmentScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">GoRoute</span><span class="p">(</span><span class="nl">path:</span> <span class="s1">&#39;/terminal&#39;</span><span class="p">,</span> <span class="nl">builder:</span> <span class="p">...</span> <span class="n">TerminalScreen</span><span class="p">()),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">]);</span></span></span></code></pre></div><p>但 HomeScreen 只有一個 <code>context.go('/terminal')</code> 按鈕，<code>/enrollment</code> 路由存在但從 UI 無法到達。</p>
<p>W2-001 修復加入 <code>OutlinedButton.icon</code> 連結到 <code>/enrollment</code>，並用 <code>context.push</code>（非 <code>context.go</code>）讓配對完成後能返回首頁。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響</td>
          <td>首次使用者無法配對（功能存在但入口缺失）</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>加一個 <code>OutlinedButton</code> + <code>context.push('/enrollment')</code></td>
      </tr>
      <tr>
          <td>根因</td>
          <td>導航流只設計了「日常連線」入口，遺漏「首次配對」入口</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>操作盤點有四個操作，首頁只有一個入口</strong>。操作盤點段列出四個操作：配對、連線、輪替、啟停。首頁應該是這四個操作的導航 hub，至少要有「配對」和「連線」兩個入口（輪替和啟停是主機端操作，不需要 app 入口）。只放 Connect Terminal 等於假設「使用者已經配對過」。</p>
</li>
<li>
<p><strong>路由存在但 UI 不可達 = 死程式碼的 UX 版本</strong>。<code>/enrollment</code> 路由在 router 裡定義了，<code>EnrollmentScreen</code> 也完整實作了，但使用者從 UI 無法觸及。這跟寫了函式但沒有呼叫者一樣 — 功能正確但不可存取。</p>
</li>
<li>
<p><strong><code>go</code> vs <code>push</code> 的語意差異影響 UX</strong>。W2 修復用 <code>context.push('/enrollment')</code> 而非 <code>context.go('/enrollment')</code> — <code>push</code> 保留返回堆疊讓使用者配對後按 back 回首頁；<code>go</code> 替換整個路由堆疊、沒有 back。這個決策影響使用者的導航體驗，但也是事後才想到的。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>導航流從操作盤點反推</strong>：每個 UC（用例）的主入口在哪？首頁應該是哪些 UC 的 hub？列出來，確認每個 UC 至少有一條從首頁可達的路徑。</li>
<li><strong>路由可達性檢查</strong>：router 定義的每個路由都應該從 UI 可達。不可達的路由要嘛是遺漏入口（本案例），要嘛是應該刪除的死路由。可以寫一個 lint 檢查。</li>
<li><strong>首次 vs 日常使用者的 UX 區分</strong>：首次使用者需要 onboarding 流程（配對 → 連線），日常使用者只需要連線。兩種入口都要在首頁可見，但可以用視覺層級區分主要/次要。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計完整導航流 → <a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">模組五：導航模式</a></li>
<li>想檢查畫面狀態矩陣的退出路徑 → <a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1 五狀態零退出</a></li>
<li>想做路由可達性自動化檢查 → 待補：Flutter GoRouter 路由可達性 lint</li>
</ul>
]]></content:encoded></item><item><title>反模式：假設使用者只走 happy path</title><link>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/anti-pattern-happy-path-only/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/anti-pattern-happy-path-only/</guid><description>&lt;p>假設使用者只走 happy path 是指 UI 設計只覆蓋「一切順利」的情境 — 連線成功後打字、配對成功後連線、操作完成後返回 — 而忽略使用者在非順利情境下的需求。非順利情境包括：等待中想取消、失敗後想換方向、成功後想離開、中途想做其他事。&lt;/p>
&lt;h2 id="隱性假設如何產生">隱性假設如何產生&lt;/h2>
&lt;p>開發者設計 connected 狀態時，注意力在「終端機介面的功能」— 打字、特殊鍵、滾動。「使用者可能想離開 connected 狀態回到首頁」這個需求在 happy path 中不存在 — 如果一切順利，使用者為什麼要離開？&lt;/p>
&lt;p>這個推論在使用者行為和開發者假設吻合時成立。但使用者可能想：&lt;/p>
&lt;ul>
&lt;li>切換到配對畫面重新配對另一台裝置&lt;/li>
&lt;li>暫時離開終端機處理其他事&lt;/li>
&lt;li>遇到回應異常想從頭重新連線&lt;/li>
&lt;li>覺得功能不符需求想回到首頁看其他選項&lt;/li>
&lt;/ul>
&lt;p>app_tunnel 的 Terminal 畫面五個狀態都沒有退出路徑。connected 狀態有打字和特殊鍵操作，但沒有「離開」操作；error 和 disconnected 有重連按鈕，但沒有「放棄重連、回首頁」的選項。開發者設計 error 狀態時的隱性假設是「使用者遇到錯誤會想重試」— 沒考慮「使用者可能想放棄」（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>）。&lt;/p>
&lt;h2 id="happy-path-偏差的擴散">Happy path 偏差的擴散&lt;/h2>
&lt;p>Happy path 偏差不只發生在單一畫面。首頁只放 Connect Terminal 按鈕、沒放配對入口，是首頁層級的 happy path 偏差 — 假設使用者已經完成配對、只需要連線（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>）。&lt;/p>
&lt;p>操作盤點的「前端引導」只描述顯示不描述操作和退出，是設計流程層級的 happy path 偏差 — 關注「順利時使用者看到什麼」，忽略「不順利時使用者能做什麼」。&lt;/p>
&lt;p>從企劃到設計到實作，每一層的 happy path 偏差累積起來，最終產出的 app 在正常情境下運作良好，在任何偏離的情境下使用者都被困住。&lt;/p>
&lt;h2 id="用狀態矩陣防止">用狀態矩陣防止&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣&lt;/a>的四欄結構（顯示 / 可用操作 / 進入條件 / 退出路徑）強制設計者回答每個狀態的操作和退出問題。不需要預測使用者的所有行為，只需要機械式地對每個狀態填寫四欄 — 空白格自動暴露缺口。&lt;/p>
&lt;p>具體的檢查規則：&lt;/p>
&lt;p>&lt;strong>退出路徑欄為空&lt;/strong>：需要補退出路徑。即使是 connecting 這種過渡狀態，使用者也應該能取消。&lt;/p>
&lt;p>&lt;strong>可用操作欄只有一個選項&lt;/strong>：使用者在這個狀態下只有一條路。如果這條路走不通（重連失敗），使用者被困住。至少考慮加一條替代路徑（返回、取消）。&lt;/p>
&lt;p>&lt;strong>進入條件是不可逆的&lt;/strong>：使用者無法自行觸發進入條件的反向操作（例如「連線成功」的反向操作「斷開連線」不存在）。這代表使用者進入後無法主動退出，只能等系統狀態變化。&lt;/p>
&lt;p>狀態矩陣的價值在於機械式的完整性檢查。填完所有狀態的四欄是一個 10 分鐘的工作（以 5 個狀態的畫面為例），產出是一張可以直接轉為 test case 的表格，同時能在實作前發現所有退出路徑缺口。&lt;/p>
&lt;p>矩陣的四欄定義和填寫步驟在&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法&lt;/a>中完整展開。如果畫面設計從 BDD 操作盤點出發，&lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/bdd-to-state-matrix/" data-link-title="從 BDD 操作盤點展開到狀態矩陣" data-link-desc="五步驟把 BDD 操作盤點的「前端引導」展開成完整的畫面狀態矩陣 — 補上操作和退出這兩個容易遺漏的面向">從 BDD 操作盤點展開到狀態矩陣&lt;/a>提供五步驟的展開流程。填完的矩陣可以直接轉成 widget test case — 具體方法見 &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 UI 自動化&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>假設使用者只走 happy path 是指 UI 設計只覆蓋「一切順利」的情境 — 連線成功後打字、配對成功後連線、操作完成後返回 — 而忽略使用者在非順利情境下的需求。非順利情境包括：等待中想取消、失敗後想換方向、成功後想離開、中途想做其他事。</p>
<h2 id="隱性假設如何產生">隱性假設如何產生</h2>
<p>開發者設計 connected 狀態時，注意力在「終端機介面的功能」— 打字、特殊鍵、滾動。「使用者可能想離開 connected 狀態回到首頁」這個需求在 happy path 中不存在 — 如果一切順利，使用者為什麼要離開？</p>
<p>這個推論在使用者行為和開發者假設吻合時成立。但使用者可能想：</p>
<ul>
<li>切換到配對畫面重新配對另一台裝置</li>
<li>暫時離開終端機處理其他事</li>
<li>遇到回應異常想從頭重新連線</li>
<li>覺得功能不符需求想回到首頁看其他選項</li>
</ul>
<p>app_tunnel 的 Terminal 畫面五個狀態都沒有退出路徑。connected 狀態有打字和特殊鍵操作，但沒有「離開」操作；error 和 disconnected 有重連按鈕，但沒有「放棄重連、回首頁」的選項。開發者設計 error 狀態時的隱性假設是「使用者遇到錯誤會想重試」— 沒考慮「使用者可能想放棄」（<a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a>）。</p>
<h2 id="happy-path-偏差的擴散">Happy path 偏差的擴散</h2>
<p>Happy path 偏差不只發生在單一畫面。首頁只放 Connect Terminal 按鈕、沒放配對入口，是首頁層級的 happy path 偏差 — 假設使用者已經完成配對、只需要連線（<a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a>）。</p>
<p>操作盤點的「前端引導」只描述顯示不描述操作和退出，是設計流程層級的 happy path 偏差 — 關注「順利時使用者看到什麼」，忽略「不順利時使用者能做什麼」。</p>
<p>從企劃到設計到實作，每一層的 happy path 偏差累積起來，最終產出的 app 在正常情境下運作良好，在任何偏離的情境下使用者都被困住。</p>
<h2 id="用狀態矩陣防止">用狀態矩陣防止</h2>
<p><a href="/blog/ux-design/knowledge-cards/screen-state-matrix/" data-link-title="畫面狀態矩陣" data-link-desc="說明用四欄表格（顯示/可用操作/進入條件/退出路徑）系統性地暴露畫面導航缺口的設計工具">畫面狀態矩陣</a>的四欄結構（顯示 / 可用操作 / 進入條件 / 退出路徑）強制設計者回答每個狀態的操作和退出問題。不需要預測使用者的所有行為，只需要機械式地對每個狀態填寫四欄 — 空白格自動暴露缺口。</p>
<p>具體的檢查規則：</p>
<p><strong>退出路徑欄為空</strong>：需要補退出路徑。即使是 connecting 這種過渡狀態，使用者也應該能取消。</p>
<p><strong>可用操作欄只有一個選項</strong>：使用者在這個狀態下只有一條路。如果這條路走不通（重連失敗），使用者被困住。至少考慮加一條替代路徑（返回、取消）。</p>
<p><strong>進入條件是不可逆的</strong>：使用者無法自行觸發進入條件的反向操作（例如「連線成功」的反向操作「斷開連線」不存在）。這代表使用者進入後無法主動退出，只能等系統狀態變化。</p>
<p>狀態矩陣的價值在於機械式的完整性檢查。填完所有狀態的四欄是一個 10 分鐘的工作（以 5 個狀態的畫面為例），產出是一張可以直接轉為 test case 的表格，同時能在實作前發現所有退出路徑缺口。</p>
<p>矩陣的四欄定義和填寫步驟在<a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">畫面狀態矩陣的定義與填寫方法</a>中完整展開。如果畫面設計從 BDD 操作盤點出發，<a href="/blog/ux-design/01-screen-state-machine/bdd-to-state-matrix/" data-link-title="從 BDD 操作盤點展開到狀態矩陣" data-link-desc="五步驟把 BDD 操作盤點的「前端引導」展開成完整的畫面狀態矩陣 — 補上操作和退出這兩個容易遺漏的面向">從 BDD 操作盤點展開到狀態矩陣</a>提供五步驟的展開流程。填完的矩陣可以直接轉成 widget test case — 具體方法見 <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 UI 自動化</a>。</p>
]]></content:encoded></item><item><title>搜尋 UX 模式</title><link>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/search-ux-pattern/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/search-ux-pattern/</guid><description>&lt;p>搜尋輸入的核心決策是「使用者輸入到什麼程度觸發搜尋」。這和 terminal 輸入的 submit model 維度相同 — 差別在 terminal 場景的選項是「整行 vs 逐字元」，搜尋場景的選項是「按送出 vs 即時 vs debounce」。&lt;/p>
&lt;h2 id="三種觸發模式">三種觸發模式&lt;/h2>
&lt;h3 id="按送出觸發">按送出觸發&lt;/h3>
&lt;p>使用者打完搜尋詞、按搜尋按鈕後觸發一次搜尋。最簡單的模式 — 一次搜尋、一次 API 呼叫、一次結果顯示。&lt;/p>
&lt;p>適合搜尋成本高的場景：資料庫全文搜尋、外部 API 呼叫（有速率限制或費用）、搜尋結果需要複雜運算。&lt;/p>
&lt;h3 id="即時觸發instant">即時觸發（instant）&lt;/h3>
&lt;p>使用者每輸入一個字元就觸發搜尋。結果即時更新，使用者可以在輸入過程中看到搜尋結果逐漸精確。&lt;/p>
&lt;p>適合搜尋成本低的場景：client 端的本地過濾、記憶體內的資料集篩選、已快取的少量資料。&lt;/p>
&lt;p>即時觸發在搜尋成本高的場景會產生問題：使用者輸入 &lt;code>hello&lt;/code> 的過程中觸發五次 API 呼叫（&lt;code>h&lt;/code>、&lt;code>he&lt;/code>、&lt;code>hel&lt;/code>、&lt;code>hell&lt;/code>、&lt;code>hello&lt;/code>），前四次的結果在使用者看到之前就被覆蓋。浪費的 API 呼叫增加 server 負載和使用者的網路流量。&lt;/p>
&lt;h3 id="debounce-觸發">Debounce 觸發&lt;/h3>
&lt;p>使用者停止輸入一段時間後（通常 300-500ms）觸發搜尋。平衡即時回饋和 API 呼叫次數 — 使用者連續打字時不觸發，停下來時觸發一次。&lt;/p>
&lt;p>Debounce 是遠端搜尋場景的常見選擇。延遲時間的設定是 UX trade-off：太短（100ms）接近即時觸發，API 呼叫次數多；太長（1000ms）使用者感覺到明顯延遲。300-500ms 是多數場景的合理區間。&lt;/p>
&lt;h2 id="搜尋結果的顯示">搜尋結果的顯示&lt;/h2>
&lt;h3 id="suggestion-list建議列表">Suggestion list（建議列表）&lt;/h3>
&lt;p>在搜尋框下方即時顯示候選結果。使用者可以點選候選項完成搜尋，不需要打完整個搜尋詞。&lt;/p>
&lt;p>Suggestion list 適合搜尋詞有限且可列舉的場景（城市名、產品名、使用者名）。搜尋詞無限（全文搜尋）時 suggestion list 的候選項品質依賴搜尋演算法。&lt;/p>
&lt;h3 id="結果頁">結果頁&lt;/h3>
&lt;p>使用者送出搜尋後導航到獨立的結果頁面。適合結果量大、需要分頁、每筆結果需要較多空間展示的場景。&lt;/p>
&lt;h3 id="即時過濾filter">即時過濾（filter）&lt;/h3>
&lt;p>在現有列表上即時隱藏不符合搜尋條件的項目，不導航到新頁面。適合「在已經看得到的清單中找到特定項目」的場景。&lt;/p>
&lt;h2 id="keyboard-type-和-textinputaction">keyboard type 和 textInputAction&lt;/h2>
&lt;p>搜尋框的 keyboard type 通常用 &lt;code>text&lt;/code>（一般文字），搭配 &lt;code>textInputAction: search&lt;/code> 讓鍵盤的 Enter 鍵顯示搜尋圖示（放大鏡）而非換行或送出圖示。&lt;/p>
&lt;p>這個細節影響使用者的操作直覺 — 看到搜尋圖示的按鈕，使用者知道按下去會觸發搜尋；看到換行圖示，使用者可能猶豫按下去會不會換行。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>四維度決策表總覽 → &lt;a href="https://tarrragon.github.io/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">輸入機制決策表&lt;/a>&lt;/li>
&lt;li>Terminal 場景的 submit model → &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/terminal-input-design/" data-link-title="Terminal app 輸入設計" data-link-desc="CLI 場景在手機上的特殊需求 — 非自然語言輸入、特殊按鍵需求、整行 vs 逐字元送出對 protocol 的影響">Terminal app 輸入設計&lt;/a>&lt;/li>
&lt;li>表單場景的驗證設計 → &lt;a href="https://tarrragon.github.io/blog/ux-design/03-input-mechanism/form-ux-pattern/" data-link-title="表單 UX 模式" data-link-desc="表單輸入的驗證時機、auto-fill 支援、錯誤回饋設計 — 和 terminal 輸入的決策維度相同但選項不同">表單 UX 模式&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>搜尋輸入的核心決策是「使用者輸入到什麼程度觸發搜尋」。這和 terminal 輸入的 submit model 維度相同 — 差別在 terminal 場景的選項是「整行 vs 逐字元」，搜尋場景的選項是「按送出 vs 即時 vs debounce」。</p>
<h2 id="三種觸發模式">三種觸發模式</h2>
<h3 id="按送出觸發">按送出觸發</h3>
<p>使用者打完搜尋詞、按搜尋按鈕後觸發一次搜尋。最簡單的模式 — 一次搜尋、一次 API 呼叫、一次結果顯示。</p>
<p>適合搜尋成本高的場景：資料庫全文搜尋、外部 API 呼叫（有速率限制或費用）、搜尋結果需要複雜運算。</p>
<h3 id="即時觸發instant">即時觸發（instant）</h3>
<p>使用者每輸入一個字元就觸發搜尋。結果即時更新，使用者可以在輸入過程中看到搜尋結果逐漸精確。</p>
<p>適合搜尋成本低的場景：client 端的本地過濾、記憶體內的資料集篩選、已快取的少量資料。</p>
<p>即時觸發在搜尋成本高的場景會產生問題：使用者輸入 <code>hello</code> 的過程中觸發五次 API 呼叫（<code>h</code>、<code>he</code>、<code>hel</code>、<code>hell</code>、<code>hello</code>），前四次的結果在使用者看到之前就被覆蓋。浪費的 API 呼叫增加 server 負載和使用者的網路流量。</p>
<h3 id="debounce-觸發">Debounce 觸發</h3>
<p>使用者停止輸入一段時間後（通常 300-500ms）觸發搜尋。平衡即時回饋和 API 呼叫次數 — 使用者連續打字時不觸發，停下來時觸發一次。</p>
<p>Debounce 是遠端搜尋場景的常見選擇。延遲時間的設定是 UX trade-off：太短（100ms）接近即時觸發，API 呼叫次數多；太長（1000ms）使用者感覺到明顯延遲。300-500ms 是多數場景的合理區間。</p>
<h2 id="搜尋結果的顯示">搜尋結果的顯示</h2>
<h3 id="suggestion-list建議列表">Suggestion list（建議列表）</h3>
<p>在搜尋框下方即時顯示候選結果。使用者可以點選候選項完成搜尋，不需要打完整個搜尋詞。</p>
<p>Suggestion list 適合搜尋詞有限且可列舉的場景（城市名、產品名、使用者名）。搜尋詞無限（全文搜尋）時 suggestion list 的候選項品質依賴搜尋演算法。</p>
<h3 id="結果頁">結果頁</h3>
<p>使用者送出搜尋後導航到獨立的結果頁面。適合結果量大、需要分頁、每筆結果需要較多空間展示的場景。</p>
<h3 id="即時過濾filter">即時過濾（filter）</h3>
<p>在現有列表上即時隱藏不符合搜尋條件的項目，不導航到新頁面。適合「在已經看得到的清單中找到特定項目」的場景。</p>
<h2 id="keyboard-type-和-textinputaction">keyboard type 和 textInputAction</h2>
<p>搜尋框的 keyboard type 通常用 <code>text</code>（一般文字），搭配 <code>textInputAction: search</code> 讓鍵盤的 Enter 鍵顯示搜尋圖示（放大鏡）而非換行或送出圖示。</p>
<p>這個細節影響使用者的操作直覺 — 看到搜尋圖示的按鈕，使用者知道按下去會觸發搜尋；看到換行圖示，使用者可能猶豫按下去會不會換行。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四維度決策表總覽 → <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></li>
<li>Terminal 場景的 submit model → <a href="/blog/ux-design/03-input-mechanism/terminal-input-design/" data-link-title="Terminal app 輸入設計" data-link-desc="CLI 場景在手機上的特殊需求 — 非自然語言輸入、特殊按鍵需求、整行 vs 逐字元送出對 protocol 的影響">Terminal app 輸入設計</a></li>
<li>表單場景的驗證設計 → <a href="/blog/ux-design/03-input-mechanism/form-ux-pattern/" data-link-title="表單 UX 模式" data-link-desc="表單輸入的驗證時機、auto-fill 支援、錯誤回饋設計 — 和 terminal 輸入的決策維度相同但選項不同">表單 UX 模式</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：錯誤狀態與回復</title><link>https://tarrragon.github.io/blog/ux-design/04-error-recovery/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/04-error-recovery/</guid><description>&lt;p>回答「出錯時使用者能做什麼」。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 錯誤訊息撰寫原則（使用者能讀懂 + 能行動）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Retry 機制 UX（自動 vs 手動 / 指數退避 vs 立即重試）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Degraded mode 設計（部分功能不可用時怎麼告知）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> error → retry → error 循環的逃生口設計&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>：error 狀態在狀態矩陣中的退出路徑&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一&lt;/a>：error 回復路徑需要 widget test 覆蓋&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一&lt;/a>：error 事件是四類事件之一&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「出錯時使用者能做什麼」。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> 錯誤訊息撰寫原則（使用者能讀懂 + 能行動）</li>
<li><input checked="" disabled="" type="checkbox"> Retry 機制 UX（自動 vs 手動 / 指數退避 vs 立即重試）</li>
<li><input checked="" disabled="" type="checkbox"> Degraded mode 設計（部分功能不可用時怎麼告知）</li>
<li><input checked="" disabled="" type="checkbox"> error → retry → error 循環的逃生口設計</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>：error 狀態在狀態矩陣中的退出路徑</li>
<li>→ <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一</a>：error 回復路徑需要 widget test 覆蓋</li>
<li>→ <a href="/blog/monitoring/01-mental-model/" data-link-title="模組一：監控心智模型" data-link-desc="四類事件（event / error / metric / lifecycle）的分類與收集策略">monitoring 模組一</a>：error 事件是四類事件之一</li>
</ul>
]]></content:encoded></item><item><title>go vs push vs pushReplacement 的 UX 語意表</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/go-push-semantics/</guid><description>&lt;p>&lt;code>go&lt;/code>、&lt;code>push&lt;/code>、&lt;code>pushReplacement&lt;/code> 三種導航方法改變導航堆疊的方式不同，直接影響使用者按 back 時的行為。選擇哪種方法的依據是使用者的操作意圖 — 使用者期望按 back 時回到哪裡。&lt;/p>
&lt;h2 id="語意對照表">語意對照表&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>堆疊行為&lt;/th>
 &lt;th>按 back 回到&lt;/th>
 &lt;th>使用者意圖&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>&lt;code>go(path)&lt;/code>&lt;/td>
 &lt;td>替換整個堆疊&lt;/td>
 &lt;td>無（離開 app）&lt;/td>
 &lt;td>切換到另一個工作區&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>push(path)&lt;/code>&lt;/td>
 &lt;td>推入堆疊頂端&lt;/td>
 &lt;td>前一個畫面&lt;/td>
 &lt;td>暫時離開，做完回來&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;code>pushReplacement&lt;/code>&lt;/td>
 &lt;td>替換堆疊頂端&lt;/td>
 &lt;td>更早的畫面&lt;/td>
 &lt;td>流程中的下一步（不可回退）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="go切換工作區">go：切換工作區&lt;/h2>
&lt;p>&lt;code>go&lt;/code> 把整個導航堆疊替換成新的路徑。使用者按 back 不會回到操作前的畫面，因為堆疊已經被替換。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>登入成功後到首頁（使用者不應該按 back 回到登入畫面）&lt;/li>
&lt;li>登出後到登入畫面（使用者不應該按 back 回到需要認證的畫面）&lt;/li>
&lt;li>從 onboarding 到主畫面（onboarding 完成後不需要回去）&lt;/li>
&lt;/ul>
&lt;p>誤用 &lt;code>go&lt;/code> 的後果：使用者期望按 back 回到前一個畫面但堆疊已空，按 back 直接離開 app。app_tunnel 修復時選擇 &lt;code>push('/enrollment')&lt;/code> 而非 &lt;code>go('/enrollment')&lt;/code>，讓使用者配對完成後能按 back 回首頁（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>）。&lt;/p>
&lt;h2 id="push暫時離開做完回來">push：暫時離開，做完回來&lt;/h2>
&lt;p>&lt;code>push&lt;/code> 在堆疊頂端加入新畫面。使用者按 back 回到前一個畫面。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>從列表到詳細頁（看完回到列表）&lt;/li>
&lt;li>從首頁到配對畫面（配對完回首頁）&lt;/li>
&lt;li>從任何畫面到設定頁（改完設定回原畫面）&lt;/li>
&lt;/ul>
&lt;p>&lt;code>push&lt;/code> 是最常用的導航方法，因為多數導航都是「暫時去另一個畫面做事，做完回來」的模式。&lt;/p>
&lt;h2 id="pushreplacement流程中前進">pushReplacement：流程中前進&lt;/h2>
&lt;p>&lt;code>pushReplacement&lt;/code> 用新畫面替換堆疊頂端。堆疊深度不變，按 back 回到替換前畫面的前一個畫面（跳過被替換的畫面）。&lt;/p>
&lt;p>適合場景：&lt;/p>
&lt;ul>
&lt;li>步驟式流程：步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 回到流程開始前的畫面，不會回到步驟 2 或 1。&lt;/li>
&lt;li>結果頁替換搜尋頁：搜尋結果替換搜尋條件頁，使用者按 back 回到搜尋前的畫面。&lt;/li>
&lt;/ul>
&lt;p>pushReplacement 的語意是「這一步完成後使用者不需要回到這裡」。用於不可回退的流程步驟。&lt;/p>
&lt;h2 id="選擇決策流程">選擇決策流程&lt;/h2>
&lt;p>對每個導航操作問一個問題：&lt;strong>使用者按 back 時，期望回到哪裡？&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>回到前一個畫面 → &lt;code>push&lt;/code>&lt;/li>
&lt;li>離開 app 或回到 app 的根畫面 → &lt;code>go&lt;/code>&lt;/li>
&lt;li>跳過當前畫面，回到更早的畫面 → &lt;code>pushReplacement&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>這個決策應該在 UX 設計階段做，記錄在畫面狀態矩陣的「退出路徑」欄中。開發者實作時對照矩陣選擇正確的導航方法。&lt;/p>
&lt;h2 id="常見誤用">常見誤用&lt;/h2>
&lt;h3 id="用-go-做應該用-push-的導航">用 go 做應該用 push 的導航&lt;/h3>
&lt;p>「首頁 → 配對畫面」如果用 &lt;code>go&lt;/code>，使用者配對完成後按 back 離開 app 而非回到首頁。使用者期望的是「配對完成回首頁」（push 行為）。&lt;/p>
&lt;h3 id="用-push-做應該用-go-的導航">用 push 做應該用 go 的導航&lt;/h3>
&lt;p>「登入 → 首頁」如果用 &lt;code>push&lt;/code>，使用者在首頁按 back 回到登入畫面。使用者已經登入，不應該看到登入畫面。&lt;/p>
&lt;h3 id="用-push-做應該用-pushreplacement-的導航">用 push 做應該用 pushReplacement 的導航&lt;/h3>
&lt;p>步驟式流程中「步驟 1 → 步驟 2」如果用 &lt;code>push&lt;/code>，使用者在步驟 2 按 back 回到步驟 1。如果步驟 1 的操作不可逆（已經提交了資料），回到步驟 1 沒有意義。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>Flutter GoRouter 的完整導航 API → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">Flutter GoRouter 導航設計&lt;/a>&lt;/li>
&lt;li>導航模式分類 → &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/" data-link-title="Mobile 導航模式分類" data-link-desc="Push/pop stack / declarative router / tab bar / drawer — 四種 mobile 導航模式各自的適用場景和使用者心理模型">Mobile 導航模式分類&lt;/a>&lt;/li>
&lt;li>路由可達性檢查 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性&lt;/a>&lt;/li>
&lt;li>導航路徑的自動化測試 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 自動化 UI 驗證&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p><code>go</code>、<code>push</code>、<code>pushReplacement</code> 三種導航方法改變導航堆疊的方式不同，直接影響使用者按 back 時的行為。選擇哪種方法的依據是使用者的操作意圖 — 使用者期望按 back 時回到哪裡。</p>
<h2 id="語意對照表">語意對照表</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>堆疊行為</th>
          <th>按 back 回到</th>
          <th>使用者意圖</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>go(path)</code></td>
          <td>替換整個堆疊</td>
          <td>無（離開 app）</td>
          <td>切換到另一個工作區</td>
      </tr>
      <tr>
          <td><code>push(path)</code></td>
          <td>推入堆疊頂端</td>
          <td>前一個畫面</td>
          <td>暫時離開，做完回來</td>
      </tr>
      <tr>
          <td><code>pushReplacement</code></td>
          <td>替換堆疊頂端</td>
          <td>更早的畫面</td>
          <td>流程中的下一步（不可回退）</td>
      </tr>
  </tbody>
</table>
<h2 id="go切換工作區">go：切換工作區</h2>
<p><code>go</code> 把整個導航堆疊替換成新的路徑。使用者按 back 不會回到操作前的畫面，因為堆疊已經被替換。</p>
<p>適合場景：</p>
<ul>
<li>登入成功後到首頁（使用者不應該按 back 回到登入畫面）</li>
<li>登出後到登入畫面（使用者不應該按 back 回到需要認證的畫面）</li>
<li>從 onboarding 到主畫面（onboarding 完成後不需要回去）</li>
</ul>
<p>誤用 <code>go</code> 的後果：使用者期望按 back 回到前一個畫面但堆疊已空，按 back 直接離開 app。app_tunnel 修復時選擇 <code>push('/enrollment')</code> 而非 <code>go('/enrollment')</code>，讓使用者配對完成後能按 back 回首頁（<a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a>）。</p>
<h2 id="push暫時離開做完回來">push：暫時離開，做完回來</h2>
<p><code>push</code> 在堆疊頂端加入新畫面。使用者按 back 回到前一個畫面。</p>
<p>適合場景：</p>
<ul>
<li>從列表到詳細頁（看完回到列表）</li>
<li>從首頁到配對畫面（配對完回首頁）</li>
<li>從任何畫面到設定頁（改完設定回原畫面）</li>
</ul>
<p><code>push</code> 是最常用的導航方法，因為多數導航都是「暫時去另一個畫面做事，做完回來」的模式。</p>
<h2 id="pushreplacement流程中前進">pushReplacement：流程中前進</h2>
<p><code>pushReplacement</code> 用新畫面替換堆疊頂端。堆疊深度不變，按 back 回到替換前畫面的前一個畫面（跳過被替換的畫面）。</p>
<p>適合場景：</p>
<ul>
<li>步驟式流程：步驟 1 → pushReplacement 步驟 2 → pushReplacement 步驟 3。使用者在步驟 3 按 back 回到流程開始前的畫面，不會回到步驟 2 或 1。</li>
<li>結果頁替換搜尋頁：搜尋結果替換搜尋條件頁，使用者按 back 回到搜尋前的畫面。</li>
</ul>
<p>pushReplacement 的語意是「這一步完成後使用者不需要回到這裡」。用於不可回退的流程步驟。</p>
<h2 id="選擇決策流程">選擇決策流程</h2>
<p>對每個導航操作問一個問題：<strong>使用者按 back 時，期望回到哪裡？</strong></p>
<ul>
<li>回到前一個畫面 → <code>push</code></li>
<li>離開 app 或回到 app 的根畫面 → <code>go</code></li>
<li>跳過當前畫面，回到更早的畫面 → <code>pushReplacement</code></li>
</ul>
<p>這個決策應該在 UX 設計階段做，記錄在畫面狀態矩陣的「退出路徑」欄中。開發者實作時對照矩陣選擇正確的導航方法。</p>
<h2 id="常見誤用">常見誤用</h2>
<h3 id="用-go-做應該用-push-的導航">用 go 做應該用 push 的導航</h3>
<p>「首頁 → 配對畫面」如果用 <code>go</code>，使用者配對完成後按 back 離開 app 而非回到首頁。使用者期望的是「配對完成回首頁」（push 行為）。</p>
<h3 id="用-push-做應該用-go-的導航">用 push 做應該用 go 的導航</h3>
<p>「登入 → 首頁」如果用 <code>push</code>，使用者在首頁按 back 回到登入畫面。使用者已經登入，不應該看到登入畫面。</p>
<h3 id="用-push-做應該用-pushreplacement-的導航">用 push 做應該用 pushReplacement 的導航</h3>
<p>步驟式流程中「步驟 1 → 步驟 2」如果用 <code>push</code>，使用者在步驟 2 按 back 回到步驟 1。如果步驟 1 的操作不可逆（已經提交了資料），回到步驟 1 沒有意義。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>Flutter GoRouter 的完整導航 API → <a href="/blog/ux-design/05-navigation-patterns/flutter-gorouter/" data-link-title="Flutter GoRouter 導航設計" data-link-desc="GoRouter 的路由定義、導航 API（go / push / pushReplacement）、redirect 機制和 ShellRoute 的使用場景">Flutter GoRouter 導航設計</a></li>
<li>導航模式分類 → <a href="/blog/ux-design/05-navigation-patterns/mobile-navigation-taxonomy/" data-link-title="Mobile 導航模式分類" data-link-desc="Push/pop stack / declarative router / tab bar / drawer — 四種 mobile 導航模式各自的適用場景和使用者心理模型">Mobile 導航模式分類</a></li>
<li>路由可達性檢查 → <a href="/blog/ux-design/01-screen-state-machine/route-reachability/" data-link-title="路由可達性檢查" data-link-desc="Router 定義的路由 vs UI 實際可達的路由 — 路由存在但 UI 不可達等於死程式碼的 UX 版本">ux-design 模組一 路由可達性</a></li>
<li>導航路徑的自動化測試 → <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四 自動化 UI 驗證</a></li>
</ul>
]]></content:encoded></item><item><title>安全敏感輸入框的 IME 控制 checklist</title><link>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/ime-security-checklist/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/03-input-mechanism/ime-security-checklist/</guid><description>&lt;p>IME（Input Method Editor）的個人化學習功能會從使用者輸入中學習新詞彙，存入 IME 詞庫，跨 app 適用。在處理 secret 的輸入框中，這個功能把密碼、API key、伺服器路徑等敏感資訊存入了 IME 的持久化儲存 — 其他 app 的使用者在輸入時可能在建議列表中看到這些內容。&lt;/p>
&lt;h2 id="為什麼是安全問題">為什麼是安全問題&lt;/h2>
&lt;p>&lt;code>enableIMEPersonalizedLearning&lt;/code> 控制的是 IME 是否從當前輸入框的內容學習新詞彙。預設值是 &lt;code>true&lt;/code> — IME 會學習使用者輸入的所有內容。&lt;/p>
&lt;p>在一般文字輸入場景中（聊天、筆記、email），IME 學習使用者的常用詞彙是合理的 — 提高打字效率，減少重複輸入。&lt;/p>
&lt;p>在 CLI 場景中（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a>），使用者可能輸入：&lt;/p>
&lt;ul>
&lt;li>資料庫密碼：&lt;code>mysql -p'MySecret123'&lt;/code>&lt;/li>
&lt;li>API key：&lt;code>curl -H 'Authorization: Bearer sk-abc123...'&lt;/code>&lt;/li>
&lt;li>伺服器路徑：&lt;code>ssh admin@192.168.1.100&lt;/code>&lt;/li>
&lt;li>環境變數：&lt;code>export DB_PASSWORD=secret&lt;/code>&lt;/li>
&lt;/ul>
&lt;p>IME 學習這些輸入後，使用者在其他 app 打字時，IME 可能在建議列表中顯示 &lt;code>MySecret123&lt;/code> 或 &lt;code>sk-abc123&lt;/code> — 任何看到螢幕的人都能看到。&lt;/p>
&lt;p>這個風險和密碼外洩的傳統路徑不同。傳統密碼外洩通常是資料庫被入侵或傳輸被攔截；IME 學習造成的洩漏是使用者在日常打字時被動暴露，使用者不會意識到 IME 記住了他們在另一個 app 輸入的密碼。&lt;/p>
&lt;h2 id="checklist">Checklist&lt;/h2>
&lt;p>處理以下任何一類內容的輸入框，應全部通過此 checklist：&lt;/p>
&lt;ul>
&lt;li>密碼、PIN 碼&lt;/li>
&lt;li>API key、token、secret&lt;/li>
&lt;li>伺服器位址、連線字串&lt;/li>
&lt;li>CLI 指令（可能包含上述任何一類）&lt;/li>
&lt;li>信用卡號碼&lt;/li>
&lt;li>任何標示為 confidential 的欄位&lt;/li>
&lt;/ul>
&lt;h3 id="必須關閉的-ime-控制">必須關閉的 IME 控制&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>個人化學習&lt;/td>
 &lt;td>&lt;code>enableIMEPersonalizedLearning: false&lt;/code>&lt;/td>
 &lt;td>防止 secret 進入 IME 詞庫&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>自動校正&lt;/td>
 &lt;td>&lt;code>autocorrect: false&lt;/code>&lt;/td>
 &lt;td>防止 secret 被替換成字典詞&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>輸入建議&lt;/td>
 &lt;td>&lt;code>enableSuggestions: false&lt;/code>&lt;/td>
 &lt;td>防止 secret 出現在建議列表&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="建議的-keyboard-type">建議的 keyboard type&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Keyboard type&lt;/th>
 &lt;th>理由&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>密碼&lt;/td>
 &lt;td>visiblePassword&lt;/td>
 &lt;td>關閉自動校正，可選顯示/隱藏&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CLI 指令&lt;/td>
 &lt;td>visiblePassword&lt;/td>
 &lt;td>需要精確輸入，不要自動校正&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>信用卡號碼&lt;/td>
 &lt;td>number&lt;/td>
 &lt;td>只需要數字鍵盤&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>連線字串&lt;/td>
 &lt;td>url&lt;/td>
 &lt;td>有 &lt;code>.&lt;/code>、&lt;code>/&lt;/code>、&lt;code>:&lt;/code> 快捷鍵&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="code-review-檢查點">Code review 檢查點&lt;/h3>
&lt;p>Review 安全敏感輸入框的 TextField 實作時，逐項確認：&lt;/p>
&lt;ol>
&lt;li>&lt;code>enableIMEPersonalizedLearning&lt;/code> 是否明確設為 &lt;code>false&lt;/code>（不依賴預設值）&lt;/li>
&lt;li>&lt;code>autocorrect&lt;/code> 是否設為 &lt;code>false&lt;/code>&lt;/li>
&lt;li>&lt;code>enableSuggestions&lt;/code> 是否設為 &lt;code>false&lt;/code>&lt;/li>
&lt;li>&lt;code>keyboardType&lt;/code> 是否選擇了不會觸發自動行為的類型&lt;/li>
&lt;li>如果是密碼欄位，&lt;code>obscureText&lt;/code> 是否按需求設定&lt;/li>
&lt;/ol>
&lt;h2 id="平台差異">平台差異&lt;/h2>
&lt;p>&lt;code>enableIMEPersonalizedLearning&lt;/code> 是 Flutter 的 API，對應到不同平台的不同機制：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>iOS&lt;/strong>：對應 &lt;code>UITextField.spellCheckingType = .no&lt;/code> 和相關 attribute。iOS 的 QuickType 學習機制由系統控制，app 只能建議不強制。&lt;/li>
&lt;li>&lt;strong>Android&lt;/strong>：對應 &lt;code>InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS&lt;/code> 等 flag。不同 IME app（Gboard、Samsung Keyboard、搜狗）對 flag 的遵守程度不一。&lt;/li>
&lt;/ul>
&lt;p>平台差異意味著 app 端的控制是「盡力而為」— 設定正確的 flag 是必要條件，但不保證所有 IME 都會遵守。安全敏感場景中，除了 IME 控制外，還應考慮 secure text entry（&lt;code>obscureText: true&lt;/code>）讓畫面上不顯示明文。&lt;/p></description><content:encoded><![CDATA[<p>IME（Input Method Editor）的個人化學習功能會從使用者輸入中學習新詞彙，存入 IME 詞庫，跨 app 適用。在處理 secret 的輸入框中，這個功能把密碼、API key、伺服器路徑等敏感資訊存入了 IME 的持久化儲存 — 其他 app 的使用者在輸入時可能在建議列表中看到這些內容。</p>
<h2 id="為什麼是安全問題">為什麼是安全問題</h2>
<p><code>enableIMEPersonalizedLearning</code> 控制的是 IME 是否從當前輸入框的內容學習新詞彙。預設值是 <code>true</code> — IME 會學習使用者輸入的所有內容。</p>
<p>在一般文字輸入場景中（聊天、筆記、email），IME 學習使用者的常用詞彙是合理的 — 提高打字效率，減少重複輸入。</p>
<p>在 CLI 場景中（<a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a>），使用者可能輸入：</p>
<ul>
<li>資料庫密碼：<code>mysql -p'MySecret123'</code></li>
<li>API key：<code>curl -H 'Authorization: Bearer sk-abc123...'</code></li>
<li>伺服器路徑：<code>ssh admin@192.168.1.100</code></li>
<li>環境變數：<code>export DB_PASSWORD=secret</code></li>
</ul>
<p>IME 學習這些輸入後，使用者在其他 app 打字時，IME 可能在建議列表中顯示 <code>MySecret123</code> 或 <code>sk-abc123</code> — 任何看到螢幕的人都能看到。</p>
<p>這個風險和密碼外洩的傳統路徑不同。傳統密碼外洩通常是資料庫被入侵或傳輸被攔截；IME 學習造成的洩漏是使用者在日常打字時被動暴露，使用者不會意識到 IME 記住了他們在另一個 app 輸入的密碼。</p>
<h2 id="checklist">Checklist</h2>
<p>處理以下任何一類內容的輸入框，應全部通過此 checklist：</p>
<ul>
<li>密碼、PIN 碼</li>
<li>API key、token、secret</li>
<li>伺服器位址、連線字串</li>
<li>CLI 指令（可能包含上述任何一類）</li>
<li>信用卡號碼</li>
<li>任何標示為 confidential 的欄位</li>
</ul>
<h3 id="必須關閉的-ime-控制">必須關閉的 IME 控制</h3>
<table>
  <thead>
      <tr>
          <th>控制項</th>
          <th>參數</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>個人化學習</td>
          <td><code>enableIMEPersonalizedLearning: false</code></td>
          <td>防止 secret 進入 IME 詞庫</td>
      </tr>
      <tr>
          <td>自動校正</td>
          <td><code>autocorrect: false</code></td>
          <td>防止 secret 被替換成字典詞</td>
      </tr>
      <tr>
          <td>輸入建議</td>
          <td><code>enableSuggestions: false</code></td>
          <td>防止 secret 出現在建議列表</td>
      </tr>
  </tbody>
</table>
<h3 id="建議的-keyboard-type">建議的 keyboard type</h3>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Keyboard type</th>
          <th>理由</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>密碼</td>
          <td>visiblePassword</td>
          <td>關閉自動校正，可選顯示/隱藏</td>
      </tr>
      <tr>
          <td>CLI 指令</td>
          <td>visiblePassword</td>
          <td>需要精確輸入，不要自動校正</td>
      </tr>
      <tr>
          <td>信用卡號碼</td>
          <td>number</td>
          <td>只需要數字鍵盤</td>
      </tr>
      <tr>
          <td>連線字串</td>
          <td>url</td>
          <td>有 <code>.</code>、<code>/</code>、<code>:</code> 快捷鍵</td>
      </tr>
  </tbody>
</table>
<h3 id="code-review-檢查點">Code review 檢查點</h3>
<p>Review 安全敏感輸入框的 TextField 實作時，逐項確認：</p>
<ol>
<li><code>enableIMEPersonalizedLearning</code> 是否明確設為 <code>false</code>（不依賴預設值）</li>
<li><code>autocorrect</code> 是否設為 <code>false</code></li>
<li><code>enableSuggestions</code> 是否設為 <code>false</code></li>
<li><code>keyboardType</code> 是否選擇了不會觸發自動行為的類型</li>
<li>如果是密碼欄位，<code>obscureText</code> 是否按需求設定</li>
</ol>
<h2 id="平台差異">平台差異</h2>
<p><code>enableIMEPersonalizedLearning</code> 是 Flutter 的 API，對應到不同平台的不同機制：</p>
<ul>
<li><strong>iOS</strong>：對應 <code>UITextField.spellCheckingType = .no</code> 和相關 attribute。iOS 的 QuickType 學習機制由系統控制，app 只能建議不強制。</li>
<li><strong>Android</strong>：對應 <code>InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS</code> 等 flag。不同 IME app（Gboard、Samsung Keyboard、搜狗）對 flag 的遵守程度不一。</li>
</ul>
<p>平台差異意味著 app 端的控制是「盡力而為」— 設定正確的 flag 是必要條件，但不保證所有 IME 都會遵守。安全敏感場景中，除了 IME 控制外，還應考慮 secure text entry（<code>obscureText: true</code>）讓畫面上不顯示明文。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>四維度決策表總覽 → <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></li>
<li>IME 個人化學習在 monitoring 中的安全考量 → <a href="/blog/monitoring/07-security-privacy/" data-link-title="模組七：資安與隱私" data-link-desc="SDK redaction / transport 加密 / collector access control / 去識別化 — 蒐集的資料本身就是風險資產">monitoring 模組七 資安</a></li>
<li>Terminal 場景的完整輸入設計 → <a href="/blog/ux-design/03-input-mechanism/terminal-input-design/" data-link-title="Terminal app 輸入設計" data-link-desc="CLI 場景在手機上的特殊需求 — 非自然語言輸入、特殊按鍵需求、整行 vs 逐字元送出對 protocol 的影響">Terminal app 輸入設計</a></li>
</ul>
]]></content:encoded></item><item><title>開發環境 vs 真機的 gate 行為差異表</title><link>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/02-gate-fallback/dev-vs-real-gate-behavior/</guid><description>&lt;p>開發環境遮蔽 gate 問題的機制是：模擬器或 debug build 中的 gate 行為比真機寬鬆，讓問題在開發階段不可見，直到實機測試或 production 才浮現。這和 mock 遮蔽 protocol 問題的機制結構相同（&lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一&lt;/a>）— 開發環境的「寬鬆模式」讓功能缺失變得不可見。&lt;/p>
&lt;h2 id="差異機制">差異機制&lt;/h2>
&lt;h3 id="模擬器不支援硬體功能">模擬器不支援硬體功能&lt;/h3>
&lt;p>iOS 模擬器不支援 Face ID / Touch ID 硬體。&lt;code>local_auth&lt;/code> 的 &lt;code>isAvailable()&lt;/code> 在模擬器上回傳 &lt;code>false&lt;/code>（&lt;code>isDeviceSupported()&lt;/code> 為 &lt;code>true&lt;/code> 但 &lt;code>getAvailableBiometrics()&lt;/code> 為空），app 跳過認證走預設路徑。&lt;/p>
&lt;p>在真機上 &lt;code>isAvailable()&lt;/code> 回傳 &lt;code>true&lt;/code>，app 嘗試認證，如果設定了 &lt;code>biometricOnly: true&lt;/code> 且 Face ID 失敗，使用者被擋住。模擬器上「跳過認證直接使用」的體驗讓開發者以為認證流程沒有問題（&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>）。&lt;/p>
&lt;h3 id="debug-build-的權限行為不同">Debug build 的權限行為不同&lt;/h3>
&lt;p>某些平台在 debug build 和 release build 的權限處理不同。例如 Android 的某些 OEM 客製化系統在 debug mode 下自動授予特定權限，release mode 下需要手動授權。&lt;/p>
&lt;h3 id="test-環境跳過-gate">Test 環境跳過 gate&lt;/h3>
&lt;p>Unit test 和 integration test 通常 mock 掉所有 gate — &lt;code>FakeBiometricService&lt;/code> 永遠回傳成功，&lt;code>FakeNetworkChecker&lt;/code> 永遠回傳已連線。這和名義 integration test 的問題相同 — test 環境的「一切正常」遮蔽了真實環境的 gate 失敗場景。&lt;/p>
&lt;h2 id="gate-行為差異表">Gate 行為差異表&lt;/h2>
&lt;p>在功能規格中建立一張差異表，列出每個 gate 在不同環境下的行為差異：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Gate&lt;/th>
 &lt;th>模擬器行為&lt;/th>
 &lt;th>真機 debug&lt;/th>
 &lt;th>真機 release&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;td>正常&lt;/td>
 &lt;td>模擬器上看不到 fallback 缺失&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>網路連線&lt;/td>
 &lt;td>通常正常（host 網路）&lt;/td>
 &lt;td>可斷 WiFi 測試&lt;/td>
 &lt;td>行動網路 + WiFi&lt;/td>
 &lt;td>模擬器的網路狀態不代表行動網路&lt;/td>
 &lt;/tr>
 &lt;tr>
 &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>藍牙&lt;/td>
 &lt;td>不支援&lt;/td>
 &lt;td>可測試&lt;/td>
 &lt;td>正常&lt;/td>
 &lt;td>模擬器完全跳過藍牙相關功能&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Push 通知&lt;/td>
 &lt;td>不支援（iOS 模擬器）&lt;/td>
 &lt;td>可測試&lt;/td>
 &lt;td>正常&lt;/td>
 &lt;td>通知觸發的導航路徑在模擬器不可測&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>App 簽名驗證&lt;/td>
 &lt;td>debug 簽名自動通過&lt;/td>
 &lt;td>debug 簽名&lt;/td>
 &lt;td>release 簽名&lt;/td>
 &lt;td>簽名相關的 gate 只在 release 生效&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="差異表的使用方式">差異表的使用方式&lt;/h2>
&lt;h3 id="開發階段">開發階段&lt;/h3>
&lt;p>開發者對照差異表，意識到哪些 gate 在當前環境下沒有被真實驗證。差異表中「模擬器行為」和「真機 release」不同的行 = 需要上真機確認的項目。&lt;/p>
&lt;h3 id="實機測試規劃">實機測試規劃&lt;/h3>
&lt;p>測試計畫中針對差異表的每一行設計測試案例。生物辨識的測試案例必須涵蓋「Face ID 失敗時的 fallback」，網路連線的測試案例必須涵蓋「飛航模式下的 UX」。&lt;/p>
&lt;h3 id="code-review">Code review&lt;/h3>
&lt;p>Review 涉及 gate 的程式碼時，對照差異表確認 fallback 路徑是否存在。如果 review 的程式碼用了 &lt;code>biometricOnly: true&lt;/code>，差異表立刻提示「模擬器上看不到這個問題，需要上真機確認 fallback」。&lt;/p>
&lt;p>差異表揭露的問題和 testing 領域的 mock 遮蔽在結構上相同 — &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">testing 模組一 Mock 遮蔽機制&lt;/a>從 API 層 vs 協議層的角度分析同一類問題。差異表本身是&lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">三問設計法&lt;/a>在實機驗證階段的延伸，biometric gate 的完整 fallback 設計見 &lt;a href="https://tarrragon.github.io/blog/ux-design/02-gate-fallback/biometric-fallback-design/" data-link-title="Biometric fallback 完整設計" data-link-desc="iOS Face ID / Touch ID 和 Android BiometricPrompt 的行為差異、fallback 策略、安全 vs 可用性取捨的顯式記錄方法">Biometric fallback 完整設計&lt;/a>。&lt;/p></description><content:encoded><![CDATA[<p>開發環境遮蔽 gate 問題的機制是：模擬器或 debug build 中的 gate 行為比真機寬鬆，讓問題在開發階段不可見，直到實機測試或 production 才浮現。這和 mock 遮蔽 protocol 問題的機制結構相同（<a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">testing 模組一</a>）— 開發環境的「寬鬆模式」讓功能缺失變得不可見。</p>
<h2 id="差異機制">差異機制</h2>
<h3 id="模擬器不支援硬體功能">模擬器不支援硬體功能</h3>
<p>iOS 模擬器不支援 Face ID / Touch ID 硬體。<code>local_auth</code> 的 <code>isAvailable()</code> 在模擬器上回傳 <code>false</code>（<code>isDeviceSupported()</code> 為 <code>true</code> 但 <code>getAvailableBiometrics()</code> 為空），app 跳過認證走預設路徑。</p>
<p>在真機上 <code>isAvailable()</code> 回傳 <code>true</code>，app 嘗試認證，如果設定了 <code>biometricOnly: true</code> 且 Face ID 失敗，使用者被擋住。模擬器上「跳過認證直接使用」的體驗讓開發者以為認證流程沒有問題（<a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a>）。</p>
<h3 id="debug-build-的權限行為不同">Debug build 的權限行為不同</h3>
<p>某些平台在 debug build 和 release build 的權限處理不同。例如 Android 的某些 OEM 客製化系統在 debug mode 下自動授予特定權限，release mode 下需要手動授權。</p>
<h3 id="test-環境跳過-gate">Test 環境跳過 gate</h3>
<p>Unit test 和 integration test 通常 mock 掉所有 gate — <code>FakeBiometricService</code> 永遠回傳成功，<code>FakeNetworkChecker</code> 永遠回傳已連線。這和名義 integration test 的問題相同 — test 環境的「一切正常」遮蔽了真實環境的 gate 失敗場景。</p>
<h2 id="gate-行為差異表">Gate 行為差異表</h2>
<p>在功能規格中建立一張差異表，列出每個 gate 在不同環境下的行為差異：</p>
<table>
  <thead>
      <tr>
          <th>Gate</th>
          <th>模擬器行為</th>
          <th>真機 debug</th>
          <th>真機 release</th>
          <th>風險</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>生物辨識</td>
          <td>跳過（硬體不可用）</td>
          <td>可測試（需設定）</td>
          <td>正常</td>
          <td>模擬器上看不到 fallback 缺失</td>
      </tr>
      <tr>
          <td>網路連線</td>
          <td>通常正常（host 網路）</td>
          <td>可斷 WiFi 測試</td>
          <td>行動網路 + WiFi</td>
          <td>模擬器的網路狀態不代表行動網路</td>
      </tr>
      <tr>
          <td>相機權限</td>
          <td>無相機（或虛擬相機）</td>
          <td>可測試</td>
          <td>正常</td>
          <td>模擬器無法測試真實權限流程</td>
      </tr>
      <tr>
          <td>藍牙</td>
          <td>不支援</td>
          <td>可測試</td>
          <td>正常</td>
          <td>模擬器完全跳過藍牙相關功能</td>
      </tr>
      <tr>
          <td>Push 通知</td>
          <td>不支援（iOS 模擬器）</td>
          <td>可測試</td>
          <td>正常</td>
          <td>通知觸發的導航路徑在模擬器不可測</td>
      </tr>
      <tr>
          <td>App 簽名驗證</td>
          <td>debug 簽名自動通過</td>
          <td>debug 簽名</td>
          <td>release 簽名</td>
          <td>簽名相關的 gate 只在 release 生效</td>
      </tr>
  </tbody>
</table>
<h2 id="差異表的使用方式">差異表的使用方式</h2>
<h3 id="開發階段">開發階段</h3>
<p>開發者對照差異表，意識到哪些 gate 在當前環境下沒有被真實驗證。差異表中「模擬器行為」和「真機 release」不同的行 = 需要上真機確認的項目。</p>
<h3 id="實機測試規劃">實機測試規劃</h3>
<p>測試計畫中針對差異表的每一行設計測試案例。生物辨識的測試案例必須涵蓋「Face ID 失敗時的 fallback」，網路連線的測試案例必須涵蓋「飛航模式下的 UX」。</p>
<h3 id="code-review">Code review</h3>
<p>Review 涉及 gate 的程式碼時，對照差異表確認 fallback 路徑是否存在。如果 review 的程式碼用了 <code>biometricOnly: true</code>，差異表立刻提示「模擬器上看不到這個問題，需要上真機確認 fallback」。</p>
<p>差異表揭露的問題和 testing 領域的 mock 遮蔽在結構上相同 — <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">testing 模組一 Mock 遮蔽機制</a>從 API 層 vs 協議層的角度分析同一類問題。差異表本身是<a href="/blog/ux-design/02-gate-fallback/gate-three-questions/" data-link-title="Gate 分類與三問設計法" data-link-desc="每個 gate 設計時問三個問題：成功時做什麼、失敗時做什麼、使用者不知道發生什麼時做什麼">三問設計法</a>在實機驗證階段的延伸，biometric gate 的完整 fallback 設計見 <a href="/blog/ux-design/02-gate-fallback/biometric-fallback-design/" data-link-title="Biometric fallback 完整設計" data-link-desc="iOS Face ID / Touch ID 和 Android BiometricPrompt 的行為差異、fallback 策略、安全 vs 可用性取捨的顯式記錄方法">Biometric fallback 完整設計</a>。</p>
]]></content:encoded></item><item><title>模組五：導航模式</title><link>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/</guid><description>&lt;p>回答「畫面之間怎麼跳」。&lt;/p>
&lt;h2 id="對應-findings">對應 findings&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>Finding&lt;/th>
 &lt;th>來源&lt;/th>
 &lt;th>內容&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>UF-10&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a>&lt;/td>
 &lt;td>go vs push 語意差異影響 UX — &lt;strong>本模組主寫&lt;/strong>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Mobile 導航模式分類（push/pop / declarative router / tab / drawer）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Flutter GoRouter 導航設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> iOS HIG vs Material Design 導航差異&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Deep link 設計&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> go vs push vs pushReplacement 的 UX 語意表&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一&lt;/a>：狀態矩陣的「退出路徑」欄位決定用 go 還是 push&lt;/li>
&lt;li>→ &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四&lt;/a>：導航路徑需要 widget test 驗證&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「畫面之間怎麼跳」。</p>
<h2 id="對應-findings">對應 findings</h2>
<table>
  <thead>
      <tr>
          <th>Finding</th>
          <th>來源</th>
          <th>內容</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UF-10</td>
          <td><a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a></td>
          <td>go vs push 語意差異影響 UX — <strong>本模組主寫</strong></td>
      </tr>
  </tbody>
</table>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Mobile 導航模式分類（push/pop / declarative router / tab / drawer）</li>
<li><input checked="" disabled="" type="checkbox"> Flutter GoRouter 導航設計</li>
<li><input checked="" disabled="" type="checkbox"> iOS HIG vs Material Design 導航差異</li>
<li><input checked="" disabled="" type="checkbox"> Deep link 設計</li>
<li><input checked="" disabled="" type="checkbox"> go vs push vs pushReplacement 的 UX 語意表</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一</a>：狀態矩陣的「退出路徑」欄位決定用 go 還是 push</li>
<li>→ <a href="/blog/testing/04-ui-automation/" data-link-title="模組四：自動化 UI 驗證" data-link-desc="Widget test 的狀態覆蓋策略、Playwright 驗證流程、螢幕狀態 coverage">testing 模組四</a>：導航路徑需要 widget test 驗證</li>
</ul>
]]></content:encoded></item><item><title>UX 設計實務指南</title><link>https://tarrragon.github.io/blog/ux-design/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/</guid><description>&lt;p>UX 設計教材的核心目標是教讀者用系統性方法捕捉 UX 設計缺口，而不是靠實機測試才發現「使用者被困住」。加 back 按鈕是 5 分鐘的事，但問題不在按鈕 — 在企劃階段沒有工具強制你為每個畫面的每個狀態想退出路徑。本教材把 UX 設計從「靠經驗想到」變成「靠方法查到」。&lt;/p>
&lt;h2 id="教學出發點">教學出發點&lt;/h2>
&lt;p>這個系列從一個具體事件出發：一個 Flutter app 的 Terminal 畫面有五個狀態（idle、connecting、connected、error、disconnected），五個狀態零個退出路徑。使用者一旦進入 Terminal 畫面就出不去，唯一的出路是殺掉 app。&lt;/p>
&lt;p>企劃文件的操作盤點確實列了「連線失敗顯示無法連線」這個失敗情境，但「前端引導」只寫了一句話 — 它覆蓋了 &lt;code>error&lt;/code> 狀態的&lt;strong>顯示&lt;/strong>，但沒有回答&lt;strong>操作&lt;/strong>（重連？返回？）和&lt;strong>退出&lt;/strong>（怎麼離開這個畫面？）。&lt;/p>
&lt;p>這揭示的是一個方法缺口：&lt;strong>從 BDD 操作盤點到 UI 實作之間，缺少一個系統性的展開步驟 — 把操作情境轉換成畫面狀態、可用操作和退出路徑的矩陣。&lt;/strong>&lt;/p>
&lt;h2 id="教學範圍">教學範圍&lt;/h2>
&lt;p>本系列聚焦「工程師能自己執行的 UX 設計方法」，不討論視覺設計、品牌、色彩理論或使用者研究方法論。&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>導航模式（mobile 的 push/pop/go、tab、drawer）&lt;/td>
 &lt;td>Design system 實作（放語言教材或 frontend 系列）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Gate fallback 設計（biometric、network、auth 的失敗替代路徑）&lt;/td>
 &lt;td>使用者研究方法（訪談、問卷、A/B test）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>輸入機制設計（keyboard type、submit model、IME policy、special keys）&lt;/td>
 &lt;td>動畫與 motion design&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>錯誤狀態與回復（error → retry / back / alternative）&lt;/td>
 &lt;td>Accessibility 標準（放獨立系列或本系列後續模組）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者行為驗證（畫面狀態 coverage test、導航路徑 test）&lt;/td>
 &lt;td>效能感知（loading skeleton、optimistic update）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="與-testing-的關係">與 Testing 的關係&lt;/h2>
&lt;p>&lt;a href="https://tarrragon.github.io/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="整理測試策略分層、協議整合驗證、客戶端可觀測性、錯誤收集與自動化驗證 — 從「測試全過但實機全壞」的結構性盲區出發，建立可操作的品質驗證體系">開發測試&lt;/a> 教材的模組四（自動化 UI 驗證）聚焦「怎麼用 test 驗證 UI 行為」。本系列聚焦「怎麼設計 UI 行為讓它可被驗證」。兩者的交叉點是畫面狀態矩陣 — 設計端產出狀態 × 操作 × 退出的表格，測試端依這張表寫 widget test 覆蓋每個狀態。&lt;/p>
&lt;h2 id="教學模組">教學模組&lt;/h2>
&lt;h3 id="模組一畫面狀態機設計">模組一：畫面狀態機設計&lt;/h3>
&lt;p>回答「這個畫面有幾個狀態、每個狀態能做什麼、怎麼離開」。核心工具是&lt;strong>畫面狀態矩陣&lt;/strong>：&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> 這張表格在 10 分鐘內暴露所有導航缺口，省掉實機測試才發現的成本。&lt;/p>
&lt;p>&lt;strong>從操作盤點到狀態矩陣的展開步驟&lt;/strong>：&lt;/p>
&lt;ol>
&lt;li>從 BDD 操作盤點列出所有畫面&lt;/li>
&lt;li>每個畫面列出所有狀態（enum 值或邏輯分支）&lt;/li>
&lt;li>填畫面狀態矩陣的四個欄位&lt;/li>
&lt;li>檢查退出路徑欄位 — 空格 = 死胡同&lt;/li>
&lt;li>每個 gate 標注 fallback&lt;/li>
&lt;/ol>
&lt;blockquote>
&lt;p>案例入口：&lt;a href="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/" data-link-title="每個畫面都需要出口：畫面狀態機設計與 UX 導航的系統性方法" data-link-desc="實機測到某畫面沒有返回或退出按鈕、使用者被困住。根因是企劃沒系統列出每個畫面的狀態與可用操作；用畫面狀態矩陣確保每個狀態都有明確出口。">每個畫面都需要出口&lt;/a> — Terminal 畫面五個狀態零個退出路徑&lt;/p>&lt;/blockquote>
&lt;h3 id="模組二gate-與-fallback-設計">模組二：Gate 與 Fallback 設計&lt;/h3>
&lt;p>回答「使用者過不了關卡時怎麼辦」。Gate = 使用者必須通過的關卡（biometric、network、auth、permission）。&lt;/p></description><content:encoded><![CDATA[<p>UX 設計教材的核心目標是教讀者用系統性方法捕捉 UX 設計缺口，而不是靠實機測試才發現「使用者被困住」。加 back 按鈕是 5 分鐘的事，但問題不在按鈕 — 在企劃階段沒有工具強制你為每個畫面的每個狀態想退出路徑。本教材把 UX 設計從「靠經驗想到」變成「靠方法查到」。</p>
<h2 id="教學出發點">教學出發點</h2>
<p>這個系列從一個具體事件出發：一個 Flutter app 的 Terminal 畫面有五個狀態（idle、connecting、connected、error、disconnected），五個狀態零個退出路徑。使用者一旦進入 Terminal 畫面就出不去，唯一的出路是殺掉 app。</p>
<p>企劃文件的操作盤點確實列了「連線失敗顯示無法連線」這個失敗情境，但「前端引導」只寫了一句話 — 它覆蓋了 <code>error</code> 狀態的<strong>顯示</strong>，但沒有回答<strong>操作</strong>（重連？返回？）和<strong>退出</strong>（怎麼離開這個畫面？）。</p>
<p>這揭示的是一個方法缺口：<strong>從 BDD 操作盤點到 UI 實作之間，缺少一個系統性的展開步驟 — 把操作情境轉換成畫面狀態、可用操作和退出路徑的矩陣。</strong></p>
<h2 id="教學範圍">教學範圍</h2>
<p>本系列聚焦「工程師能自己執行的 UX 設計方法」，不討論視覺設計、品牌、色彩理論或使用者研究方法論。</p>
<table>
  <thead>
      <tr>
          <th>放在本系列</th>
          <th>放在其他系列</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>畫面狀態機設計（狀態 × 操作 × 進入 × 退出矩陣）</td>
          <td>視覺設計（色彩、字型、間距）</td>
      </tr>
      <tr>
          <td>導航模式（mobile 的 push/pop/go、tab、drawer）</td>
          <td>Design system 實作（放語言教材或 frontend 系列）</td>
      </tr>
      <tr>
          <td>Gate fallback 設計（biometric、network、auth 的失敗替代路徑）</td>
          <td>使用者研究方法（訪談、問卷、A/B test）</td>
      </tr>
      <tr>
          <td>輸入機制設計（keyboard type、submit model、IME policy、special keys）</td>
          <td>動畫與 motion design</td>
      </tr>
      <tr>
          <td>錯誤狀態與回復（error → retry / back / alternative）</td>
          <td>Accessibility 標準（放獨立系列或本系列後續模組）</td>
      </tr>
      <tr>
          <td>使用者行為驗證（畫面狀態 coverage test、導航路徑 test）</td>
          <td>效能感知（loading skeleton、optimistic update）</td>
      </tr>
  </tbody>
</table>
<h2 id="與-testing-的關係">與 Testing 的關係</h2>
<p><a href="/blog/testing/" data-link-title="開發測試實務指南" data-link-desc="整理測試策略分層、協議整合驗證、客戶端可觀測性、錯誤收集與自動化驗證 — 從「測試全過但實機全壞」的結構性盲區出發，建立可操作的品質驗證體系">開發測試</a> 教材的模組四（自動化 UI 驗證）聚焦「怎麼用 test 驗證 UI 行為」。本系列聚焦「怎麼設計 UI 行為讓它可被驗證」。兩者的交叉點是畫面狀態矩陣 — 設計端產出狀態 × 操作 × 退出的表格，測試端依這張表寫 widget test 覆蓋每個狀態。</p>
<h2 id="教學模組">教學模組</h2>
<h3 id="模組一畫面狀態機設計">模組一：畫面狀態機設計</h3>
<p>回答「這個畫面有幾個狀態、每個狀態能做什麼、怎麼離開」。核心工具是<strong>畫面狀態矩陣</strong>：</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> 這張表格在 10 分鐘內暴露所有導航缺口，省掉實機測試才發現的成本。</p>
<p><strong>從操作盤點到狀態矩陣的展開步驟</strong>：</p>
<ol>
<li>從 BDD 操作盤點列出所有畫面</li>
<li>每個畫面列出所有狀態（enum 值或邏輯分支）</li>
<li>填畫面狀態矩陣的四個欄位</li>
<li>檢查退出路徑欄位 — 空格 = 死胡同</li>
<li>每個 gate 標注 fallback</li>
</ol>
<blockquote>
<p>案例入口：<a href="/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/" data-link-title="每個畫面都需要出口：畫面狀態機設計與 UX 導航的系統性方法" data-link-desc="實機測到某畫面沒有返回或退出按鈕、使用者被困住。根因是企劃沒系統列出每個畫面的狀態與可用操作；用畫面狀態矩陣確保每個狀態都有明確出口。">每個畫面都需要出口</a> — Terminal 畫面五個狀態零個退出路徑</p></blockquote>
<h3 id="模組二gate-與-fallback-設計">模組二：Gate 與 Fallback 設計</h3>
<p>回答「使用者過不了關卡時怎麼辦」。Gate = 使用者必須通過的關卡（biometric、network、auth、permission）。</p>
<table>
  <thead>
      <tr>
          <th>Gate 類型</th>
          <th>設計問題</th>
          <th>反模式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Biometric</td>
          <td>Face ID 不可用（光線、口罩、設備）→ 替代？</td>
          <td><code>biometricOnly: true</code>（無 fallback）</td>
      </tr>
      <tr>
          <td>Network</td>
          <td>VPN 離線、DNS 失敗 → 替代？</td>
          <td>直接顯示錯誤碼（使用者看不懂）</td>
      </tr>
      <tr>
          <td>Auth</td>
          <td>Token 過期、密碼錯誤 → 替代？</td>
          <td>靜默失敗（使用者不知道發生什麼）</td>
      </tr>
      <tr>
          <td>Permission</td>
          <td>相機/位置權限被拒 → 替代？</td>
          <td>只說「需要權限」不說為什麼</td>
      </tr>
  </tbody>
</table>
<p>每個 gate 的設計至少要回答三個問題：成功時做什麼？失敗時做什麼？使用者不知道發生什麼事時做什麼？</p>
<blockquote>
<p>後續章節預定：biometric fallback 完整設計、網路斷線 UX 模式、Permission 請求時機與措辭</p></blockquote>
<h3 id="模組三輸入機制設計">模組三：輸入機制設計</h3>
<p>回答「使用者怎麼輸入資料」。特別針對 mobile 環境，手機鍵盤和桌面鍵盤的差異比想像的大。</p>
<table>
  <thead>
      <tr>
          <th>設計維度</th>
          <th>桌面預設</th>
          <th>Mobile 需明確設計</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Keyboard type</td>
          <td>實體鍵盤，所有鍵都有</td>
          <td>虛擬鍵盤，需選 type（text / number / email / visiblePassword）</td>
      </tr>
      <tr>
          <td>自動校正</td>
          <td>通常關閉或可忽略</td>
          <td>預設開啟，可能「幫忙」修改 CLI 命令</td>
      </tr>
      <tr>
          <td>Special keys</td>
          <td>Esc / Tab / Ctrl 都有</td>
          <td>沒有，需要自訂工具列</td>
      </tr>
      <tr>
          <td>Submit</td>
          <td>Enter 換行或送出</td>
          <td>需明確選擇 <code>textInputAction</code>（send / done / next / newline）</td>
      </tr>
      <tr>
          <td>IME</td>
          <td>輸入法不影響程式碼輸入</td>
          <td>中文輸入法的建議列可能遮擋畫面</td>
      </tr>
  </tbody>
</table>
<p>這些決策影響 UI layout 和 protocol 設計，應該在企劃階段做，不是實作時臨時決定。</p>
<blockquote>
<p>後續章節預定：terminal app 輸入設計、表單 UX 模式、搜尋 UX 模式</p></blockquote>
<h3 id="模組四錯誤狀態與回復">模組四：錯誤狀態與回復</h3>
<p>回答「出錯時使用者能做什麼」。錯誤不只是紅色文字 — 是一個需要設計退出路徑的狀態。</p>
<blockquote>
<p>後續章節預定：錯誤訊息撰寫原則、retry 機制 UX、degraded mode 設計</p></blockquote>
<h3 id="模組五導航模式">模組五：導航模式</h3>
<p>回答「畫面之間怎麼跳」。Mobile 導航模式的選擇（push/pop stack、GoRouter 命名路由、tab bar、drawer）和每種模式的 UX 差異。</p>
<blockquote>
<p>後續章節預定：Flutter GoRouter 導航設計、iOS HIG vs Material Design 導航差異、deep link 設計</p></blockquote>
<h2 id="學習路線">學習路線</h2>
<table>
  <thead>
      <tr>
          <th>路線</th>
          <th>適合讀者</th>
          <th>建議順序</th>
          <th>讀完能做什麼</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>UX 防呆入門</td>
          <td>想避免「使用者被困住」的基本問題</td>
          <td>模組一 → 模組二 → 模組四</td>
          <td>能用狀態矩陣捕捉所有 UX 死胡同</td>
      </tr>
      <tr>
          <td>Mobile 輸入設計</td>
          <td>做 mobile app 但鍵盤行為總是出問題</td>
          <td>模組三 → 模組一（狀態中的輸入）</td>
          <td>能設計完整的 mobile 輸入方案</td>
      </tr>
      <tr>
          <td>全棧 UX 設計</td>
          <td>想建立系統性的 UX 設計流程</td>
          <td>模組一 → 模組二 → 模組三 → 模組四 → 模組五</td>
          <td>能從操作盤點展開完整的 UX 設計文件</td>
      </tr>
  </tbody>
</table>
<h2 id="教學寫作方向">教學寫作方向</h2>
<p>本系列的寫作原則：</p>
<ol>
<li><strong>方法先於工具</strong> — 先教狀態矩陣怎麼填，再討論用什麼 framework 實作。Figma、Sketch、紙筆都能畫狀態矩陣</li>
<li><strong>工程師視角</strong> — 不假設讀者有設計背景。用程式碼的 enum、state machine、if-else 類比解釋 UX 概念</li>
<li><strong>反模式驅動</strong> — 每個設計原則都從一個真實反模式出發（「五個狀態零個退出路徑」），先讓讀者感受問題，再教方法</li>
<li><strong>可測試的設計</strong> — 設計產出物（狀態矩陣）要能直接轉成 test case。設計和測試不是兩件事</li>
</ol>
<hr>
<p><em>文件版本：v0.1.0</em>
<em>最後更新：2026-06-19</em>
<em>系列狀態：分類索引建立中</em></p>
]]></content:encoded></item><item><title>UX 設計案例庫</title><link>https://tarrragon.github.io/blog/ux-design/cases/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/cases/</guid><description>&lt;p>這個資料夾收錄 UX 設計的實戰案例 — 重點不在「畫面怎麼設計」，而在「設計方法的哪個步驟遺漏了什麼」。每個案例記錄一個真實的 UX 缺口、分析企劃階段的遺漏機制、提出系統性的預防方法。&lt;/p>
&lt;p>案例來源分兩類：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>自有案例&lt;/strong>：app_tunnel 專案的實機測試教訓（first-party，有完整程式碼和 commit 歷史）&lt;/li>
&lt;li>&lt;strong>外部案例&lt;/strong>：iOS/Android 設計指南中的反模式和社群討論（third-party，引用公開來源）&lt;/li>
&lt;/ul>
&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>mobile CLI app 的鍵盤設計案例&lt;/td>
 &lt;td>小眾需求，公開案例稀少&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>模組五（導航模式）&lt;/td>
 &lt;td>GoRouter vs Navigator 2.0 的導航 UX 差異案例&lt;/td>
 &lt;td>Flutter 社群有討論但少系統化&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="案例列表">案例列表&lt;/h2>
&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>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1&lt;/a>&lt;/td>
 &lt;td>五個狀態零個退出路徑&lt;/td>
 &lt;td>app_tunnel&lt;/td>
 &lt;td>模組一&lt;/td>
 &lt;td>狀態機退出路徑未設計&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2&lt;/a>&lt;/td>
 &lt;td>biometricOnly=true 無密碼 fallback&lt;/td>
 &lt;td>app_tunnel&lt;/td>
 &lt;td>模組二&lt;/td>
 &lt;td>Gate 無 fallback&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>| &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &amp;#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3&lt;/a> | 終端機文字輸入機制未設計 | app_tunnel | 模組三 | 輸入機制是事後 hotfix |
| &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4&lt;/a> | 首頁缺配對入口按鈕 | app_tunnel | 模組一 | 導航流未完整列出 |&lt;/p></description><content:encoded><![CDATA[<p>這個資料夾收錄 UX 設計的實戰案例 — 重點不在「畫面怎麼設計」，而在「設計方法的哪個步驟遺漏了什麼」。每個案例記錄一個真實的 UX 缺口、分析企劃階段的遺漏機制、提出系統性的預防方法。</p>
<p>案例來源分兩類：</p>
<ul>
<li><strong>自有案例</strong>：app_tunnel 專案的實機測試教訓（first-party，有完整程式碼和 commit 歷史）</li>
<li><strong>外部案例</strong>：iOS/Android 設計指南中的反模式和社群討論（third-party，引用公開來源）</li>
</ul>
<h2 id="案例覆蓋缺口">案例覆蓋缺口</h2>
<table>
  <thead>
      <tr>
          <th>章節</th>
          <th>缺口</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>模組三（輸入機制設計）</td>
          <td>mobile CLI app 的鍵盤設計案例</td>
          <td>小眾需求，公開案例稀少</td>
      </tr>
      <tr>
          <td>模組五（導航模式）</td>
          <td>GoRouter vs Navigator 2.0 的導航 UX 差異案例</td>
          <td>Flutter 社群有討論但少系統化</td>
      </tr>
  </tbody>
</table>
<h2 id="案例列表">案例列表</h2>
<table>
  <thead>
      <tr>
          <th>案例</th>
          <th>主題</th>
          <th>來源</th>
          <th>模組</th>
          <th>缺口類型</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><a href="/blog/ux-design/cases/five-states-zero-exits/" data-link-title="U.C1 Terminal 畫面五個狀態零個退出路徑" data-link-desc="Flutter app 的 Terminal 畫面有 idle/connecting/connected/error/disconnected 五個 enum 狀態，每個狀態都沒有 back 或 disconnect 按鈕 — 使用者一旦進入就出不去">U.C1</a></td>
          <td>五個狀態零個退出路徑</td>
          <td>app_tunnel</td>
          <td>模組一</td>
          <td>狀態機退出路徑未設計</td>
      </tr>
      <tr>
          <td><a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2</a></td>
          <td>biometricOnly=true 無密碼 fallback</td>
          <td>app_tunnel</td>
          <td>模組二</td>
          <td>Gate 無 fallback</td>
      </tr>
  </tbody>
</table>
<p>| <a href="/blog/ux-design/cases/terminal-input-mechanism-absent/" data-link-title="U.C3 終端機文字輸入機制未設計、事後 hotfix 補 TextField" data-link-desc="Flutter 終端機 app 的鍵盤輸入完全未設計 — 沒有 TextField、沒有 keyboard type 選擇、沒有 IME 控制。W2 修復時才補上 TextField &#43; 6 個參數（enableSuggestions/autocorrect/enableIMEPersonalizedLearning/keyboardType/textInputAction/onSubmitted），全是散落 hotfix">U.C3</a> | 終端機文字輸入機制未設計 | app_tunnel | 模組三 | 輸入機制是事後 hotfix |
| <a href="/blog/ux-design/cases/missing-enrollment-entry-point/" data-link-title="U.C4 首頁缺配對入口按鈕、導航流未完整列出" data-link-desc="Flutter app 首頁只有 Connect Terminal 按鈕、沒有 Enroll Device 入口 — 使用者首次使用時找不到配對功能。根因是導航流設計只考慮了日常操作（UC-02 連線）、遺漏了首次操作（UC-01 配對）的入口">U.C4</a>  | 首頁缺配對入口按鈕       | app_tunnel | 模組一 | 導航流未完整列出      |</p>
]]></content:encoded></item><item><title>UX Design 知識卡片</title><link>https://tarrragon.github.io/blog/ux-design/knowledge-cards/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/ux-design/knowledge-cards/</guid><description>&lt;p>UX 設計教學中出現的關鍵術語卡片。每張卡片說明一個語意責任，跨情境變義的概念拆成獨立卡片。&lt;/p></description><content:encoded>&lt;p>UX 設計教學中出現的關鍵術語卡片。每張卡片說明一個語意責任，跨情境變義的概念拆成獨立卡片。&lt;/p>
</content:encoded></item></channel></rss>