<?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>Navigation on Tarragon</title><link>https://tarrragon.github.io/blog/tags/navigation/</link><description>Recent content in Navigation 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/navigation/index.xml" rel="self" type="application/rss+xml"/><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>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>從 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>導航路徑 test</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/</guid><description>&lt;p>導航路徑 test 驗證的是使用者在畫面之間的移動是否符合設計 — 每個畫面的 back 按鈕是否導向正確的上層畫面、每個 router 定義的路由是否從 UI 可達、&lt;code>go&lt;/code> 和 &lt;code>push&lt;/code> 的語意是否產生正確的返回堆疊。&lt;/p>
&lt;h2 id="back-按鈕-test">Back 按鈕 test&lt;/h2>
&lt;p>每個有 back 按鈕的畫面需要一個 test 驗證「按下 back 後導航到哪裡」。Back 按鈕的目標畫面依導航方式而定：&lt;/p>
&lt;ul>
&lt;li>&lt;code>context.push('/terminal')&lt;/code> 進入 → back 回到推入前的畫面（首頁）&lt;/li>
&lt;li>&lt;code>context.go('/terminal')&lt;/code> 進入 → back 行為依 router 設定，可能沒有上一頁&lt;/li>
&lt;/ul>





&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">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back from terminal returns to home (pushed)&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&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="c1">// 從首頁 push 到 terminal
&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Connect Terminal&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">TerminalScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&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="c1">// 按 back
&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byKey&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">Key&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;back_button&amp;#39;&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="route-可達性-test">Route 可達性 test&lt;/h2>
&lt;p>Router 定義的每個路由都應該有從 UI 可達的路徑（&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>）。Route 可達性 test 驗證「從首頁出發，透過 UI 操作能到達每個路由」。&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">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;enrollment route is reachable from home&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&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="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="kd">final&lt;/span> &lt;span class="n">enrollButton&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Enroll Device&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">enrollButton&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">findsOneWidget&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="c1">// 點擊後到達 enrollment 畫面
&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">enrollButton&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">EnrollmentScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不可達的路由在 test 中表現為「找不到導航到該路由的 UI 元素」。如果 router 定義了 &lt;code>/enrollment&lt;/code> 但首頁沒有對應按鈕，&lt;code>find.text('Enroll Device')&lt;/code> 會找不到元素 — test 失敗暴露入口缺失。&lt;/p>
&lt;h2 id="go-vs-push-語意的-test">&lt;code>go&lt;/code> vs &lt;code>push&lt;/code> 語意的 test&lt;/h2>
&lt;p>&lt;code>go&lt;/code> 和 &lt;code>push&lt;/code> 對返回堆疊的影響不同（&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>）。Test 需要驗證正確的導航方式被使用：&lt;/p>
&lt;h3 id="push-語意保留返回堆疊">Push 語意：保留返回堆疊&lt;/h3>
&lt;p>Push 後按系統 back 鍵應該回到推入前的畫面。&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">testWidgets&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;push preserves back stack&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">tester&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="kd">async&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpWidget&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">app&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="c1">// push to enrollment
&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Enroll Device&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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="c1">// 系統 back 鍵
&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="kd">final&lt;/span> &lt;span class="n">backButton&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byTooltip&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Back&amp;#39;&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">tap&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">backButton&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="kd">await&lt;/span> &lt;span class="n">tester&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">pumpAndSettle&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="c1">// 應該回到首頁
&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">find&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">byType&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">HomeScreen&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">findsOneWidget&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="go-語意替換路由堆疊">Go 語意：替換路由堆疊&lt;/h3>
&lt;p>Go 後按系統 back 鍵的行為依 router 設定。如果 go 到的路由是根層級，系統 back 鍵可能退出 app 而非回到前一個畫面。&lt;/p></description><content:encoded><![CDATA[<p>導航路徑 test 驗證的是使用者在畫面之間的移動是否符合設計 — 每個畫面的 back 按鈕是否導向正確的上層畫面、每個 router 定義的路由是否從 UI 可達、<code>go</code> 和 <code>push</code> 的語意是否產生正確的返回堆疊。</p>
<h2 id="back-按鈕-test">Back 按鈕 test</h2>
<p>每個有 back 按鈕的畫面需要一個 test 驗證「按下 back 後導航到哪裡」。Back 按鈕的目標畫面依導航方式而定：</p>
<ul>
<li><code>context.push('/terminal')</code> 進入 → back 回到推入前的畫面（首頁）</li>
<li><code>context.go('/terminal')</code> 進入 → back 行為依 router 設定，可能沒有上一頁</li>
</ul>





<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">testWidgets</span><span class="p">(</span><span class="s1">&#39;back from terminal returns to home (pushed)&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 從首頁 push 到 terminal
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Connect Terminal&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="c1">// 按 back
</span></span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byKey</span><span class="p">(</span><span class="n">Key</span><span class="p">(</span><span class="s1">&#39;back_button&#39;</span><span class="p">)));</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="route-可達性-test">Route 可達性 test</h2>
<p>Router 定義的每個路由都應該有從 UI 可達的路徑（<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>）。Route 可達性 test 驗證「從首頁出發，透過 UI 操作能到達每個路由」。</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">testWidgets</span><span class="p">(</span><span class="s1">&#39;enrollment route is reachable from home&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// 找到配對入口按鈕
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">enrollButton</span> <span class="o">=</span> <span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Enroll Device&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">enrollButton</span><span class="p">,</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// 點擊後到達 enrollment 畫面
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">enrollButton</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">EnrollmentScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>不可達的路由在 test 中表現為「找不到導航到該路由的 UI 元素」。如果 router 定義了 <code>/enrollment</code> 但首頁沒有對應按鈕，<code>find.text('Enroll Device')</code> 會找不到元素 — test 失敗暴露入口缺失。</p>
<h2 id="go-vs-push-語意的-test"><code>go</code> vs <code>push</code> 語意的 test</h2>
<p><code>go</code> 和 <code>push</code> 對返回堆疊的影響不同（<a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式</a>）。Test 需要驗證正確的導航方式被使用：</p>
<h3 id="push-語意保留返回堆疊">Push 語意：保留返回堆疊</h3>
<p>Push 後按系統 back 鍵應該回到推入前的畫面。</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">testWidgets</span><span class="p">(</span><span class="s1">&#39;push preserves back stack&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">());</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="c1">// push to enrollment
</span></span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"></span>  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">text</span><span class="p">(</span><span class="s1">&#39;Enroll Device&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// 系統 back 鍵
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="kd">final</span> <span class="n">backButton</span> <span class="o">=</span> <span class="n">find</span><span class="p">.</span><span class="n">byTooltip</span><span class="p">(</span><span class="s1">&#39;Back&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">tap</span><span class="p">(</span><span class="n">backButton</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpAndSettle</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="c1">// 應該回到首頁
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span>  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">HomeScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="go-語意替換路由堆疊">Go 語意：替換路由堆疊</h3>
<p>Go 後按系統 back 鍵的行為依 router 設定。如果 go 到的路由是根層級，系統 back 鍵可能退出 app 而非回到前一個畫面。</p>
<p>Test 策略：驗證 go 後的路由堆疊狀態。如果設計意圖是「切換工作區，不保留前一個畫面」，斷言系統 back 鍵不回到前一個畫面。</p>
<h2 id="深層連結-test">深層連結 test</h2>
<p>深層連結（deep link）讓使用者從 app 外部直接進入特定畫面。Deep link test 驗證「直接導航到內部路由時，畫面和導航堆疊是否正確」。</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">testWidgets</span><span class="p">(</span><span class="s1">&#39;deep link to /terminal shows terminal&#39;</span><span class="p">,</span> <span class="p">(</span><span class="n">tester</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kd">await</span> <span class="n">tester</span><span class="p">.</span><span class="n">pumpWidget</span><span class="p">(</span><span class="n">app</span><span class="p">(</span><span class="nl">initialRoute:</span> <span class="s1">&#39;/terminal&#39;</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">find</span><span class="p">.</span><span class="n">byType</span><span class="p">(</span><span class="n">TerminalScreen</span><span class="p">),</span> <span class="n">findsOneWidget</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>深層連結的特殊性在於使用者跳過了正常的導航流程。從首頁到 terminal 的正常流程可能經過認證 gate，但深層連結直接到 terminal — 認證 gate 是否仍然生效需要額外的 test。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>狀態覆蓋策略 → <a href="/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略</a></li>
<li>Playwright 驗證流程 → <a href="/blog/testing/04-ui-automation/playwright-verification/" data-link-title="Playwright 瀏覽器驗證流程" data-link-desc="用 Playwright 驗證 web 版本的 UI 行為 — test 結構、selector 策略、和 widget test 的互補關係">Playwright 瀏覽器驗證流程</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>
</ul>
]]></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>路由可達性檢查</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>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>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>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>模組五：導航模式</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/work-log/%E6%AF%8F%E5%80%8B%E7%95%AB%E9%9D%A2%E9%83%BD%E9%9C%80%E8%A6%81%E5%87%BA%E5%8F%A3%E7%95%AB%E9%9D%A2%E7%8B%80%E6%85%8B%E6%A9%9F%E8%A8%AD%E8%A8%88%E8%88%87-ux-%E5%B0%8E%E8%88%AA%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%96%B9%E6%B3%95/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/work-log/%E6%AF%8F%E5%80%8B%E7%95%AB%E9%9D%A2%E9%83%BD%E9%9C%80%E8%A6%81%E5%87%BA%E5%8F%A3%E7%95%AB%E9%9D%A2%E7%8B%80%E6%85%8B%E6%A9%9F%E8%A8%AD%E8%A8%88%E8%88%87-ux-%E5%B0%8E%E8%88%AA%E7%9A%84%E7%B3%BB%E7%B5%B1%E6%80%A7%E6%96%B9%E6%B3%95/</guid><description>&lt;h2 id="這篇要解決什麼">這篇要解決什麼&lt;/h2>
&lt;blockquote>
&lt;p>使用者連上遠端終端機後、無法返回首頁。&lt;/p>&lt;/blockquote>
&lt;p>這是設計遺漏。Terminal 畫面的 &lt;code>connected&lt;/code> 狀態沒有 disconnect 按鈕也沒有 back 按鈕。&lt;code>error&lt;/code> 和 &lt;code>disconnected&lt;/code> 狀態也沒有。使用者被困在畫面裡，唯一的出路是殺掉 app。&lt;/p>
&lt;p>這不是「忘記加按鈕」的問題。回頭看企劃文件，操作盤點段確實列了「連線失敗顯示無法連線」這個失敗情境，但沒有系統性地問：&lt;strong>這個畫面有幾個狀態？每個狀態能做什麼操作？怎麼離開？&lt;/strong>&lt;/p>
&lt;p>本文整理畫面狀態機設計的方法、示範用狀態矩陣捕捉導航缺口、歸納 mobile app UX 的三個設計原則。&lt;/p>
&lt;hr>
&lt;h2 id="實際案例terminal-畫面的五個狀態">實際案例：Terminal 畫面的五個狀態&lt;/h2>
&lt;p>Terminal 畫面有一個 &lt;code>TerminalScreenUiState&lt;/code> enum 定義了五個狀態：&lt;/p>





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





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