` | 高 8px、`color.destructive` fill(強調危險)、`color.muted` track、圓角 `radius.full` |
| 建議文字 | `
` | 13px / regular / `color.warning`、附 ⚠ icon |
| **「繼續等待」按鈕** | Button `primary` `md` | **預設聚焦**(focus)、Enter 觸發、樣式為主要 CTA |
| **「強制關閉」按鈕** | Button `destructive ghost` `md` | 邊框 `color.destructive`、文字 `color.destructive`、背景透明(不主動誘導點擊)|
### 6a.5 互動行為
**「繼續等待」**(建議路徑):
- 點擊 / Enter → 關閉 modal、保留控制台視窗
- 控制台留在原狀態(韌體進度面板繼續顯示、log 持續更新)
- 不結束 server、不關 Wails 視窗
- 韌體任務完成後、firmware-management §5.4 / §6.4 toast 出現、使用者可以再次嘗試關閉視窗(這次走正常 R5-2 流程)
**「強制關閉」**(危險路徑):
- 點擊 → **不立即關閉**、跳出第二層**最終確認 modal**:
```
┌─ 🚫 確定要強制關閉? ────────────────────────────────┐
│ │
│ 強制關閉會: │
│ • 中斷正在進行的韌體寫入 │
│ • 可能造成裝置永久損毀(無法救援) │
│ • 即使重新插拔也無法復原 │
│ │
│ 請輸入「FORCE」確認你了解後果: │
│ ┌──────────────────────────────────────────┐ │
│ │ │ │
│ └──────────────────────────────────────────┘ │
│ (大小寫需完全相同) │
│ │
│ [ 返回 ] [ 確認強制關閉 ] │
│ │
└───────────────────────────────────────────────────────┘
```
- 「FORCE」輸入框比對與 firmware-management §6.1 「DOWNGRADE」相同邏輯(嚴格 `===` 比對)
- 「確認強制關閉」按鈕在輸入 `FORCE` 前 disabled、輸入正確後變為 `destructive` 紅色
- 點擊 → 前端呼叫 Wails binding `App.ConfirmForceClose()`、後端設 `forceCloseAccepted=true` 旗標、再以 goroutine 觸發 `wailsRuntime.Quit(ctx)` 關閉視窗
- 下一輪 `OnBeforeClose` 看到旗標已設、直接放行 → 走 既有 `OnShutdown` 7+1s **graceful shutdown** 流程(呼叫 `ctrl.Stop()`、server 端再以 `SIGTERM` + `AwaitActiveTasksOrTimeout(180s)` 雙層防護收尾)
- **設計刻意採 graceful 而非 SIGKILL**:
- 即使使用者已通過「FORCE」二次確認、仍給 server 一個有限的時間窗(最長 180s)讓 Python sidecar 嘗試把當前 flash 寫入 block 收尾(chip 內部 atomic-write boundary)
- 多數情況下 firmware task 會在 180s 內自然完成、實際關閉延遲可能只有數秒
- 真正卡死的場景才會撐到 180s timeout、之後 server 強制走 shutdown、再 `os.Exit(0)`
- 比起直接 SIGKILL「砍 process = 砍寫入 = 必定 brick」、graceful 路徑至少給裝置一個「可能不 brick」的機會
- 使用者體感:點完「確認強制關閉」後視窗不會瞬間消失、可能停留數秒到 180s(極端)才關閉;可在 modal 關閉前暫留 Stopping... 狀態提示
**ESC 鍵**:等同點「繼續等待」(安全路徑)、關 modal 保留視窗
**外部點擊**:不可關閉(捕獲事件、抖動 modal 200ms 提示)、與 firmware-management §6.1 同設計
### 6a.6 視覺強度層級對比
| 元素 | firmware-management §6.1 降版二次確認 | 本節 §6a.3 韌體進行中關閉攔截 |
|------|------------------------------------|---------------------------|
| 場景 | 降版**開始前** | 降版**進行中** + 使用者試圖中斷 |
| 風險等級 | 中(降版本身有 brick 風險、但 < 1%)| 高(中斷正在寫 flash = 高機率 brick)|
| 確認字串 | `DOWNGRADE`(指向操作意圖)| `FORCE`(指向強制中斷)|
| Modal 邊框 | 1px | **2px**(更強烈)|
| 預設聚焦 | 「DOWNGRADE」input | **「繼續等待」按鈕**(引導安全路徑)|
| 安全 CTA | 「取消」`ghost` | **「繼續等待」`primary`**(主要 CTA、強引導)|
| 危險 CTA | 「確認切換」`destructive` filled(input 通過後)| 「強制關閉」`destructive ghost`(不主動誘導、需二次確認)|
### 6a.7 不重複攔截
- 同一個關閉嘗試只跳一次 modal
- 使用者選「繼續等待」後、韌體完成前如再次嘗試關閉 → 重新跑 6a.2 流程(再次 query server status)
- 「強制關閉」確認後、modal 立即關閉、不留任何撤銷機會(已通過二次確認)
### 6a.8 與 Footer 持久提示 §4.6 的關係
- §4.6 「⚠ Closing this window will stop the server.」footer 提示**仍然顯示**
- 韌體進行中時、footer 提示**改為更嚴重的紅色版本**:「🚫 韌體更新中、關閉視窗可能造成裝置損毀」
- 對應 i18n key:`control.footer.closeWarningFirmwareActive`
### 6a.9 i18n keys(新增)
| Key | zh-TW | en |
|-----|-------|----|
| `control.firmwareBlock.title` | 韌體更新進行中 | Firmware update in progress |
| `control.firmwareBlock.heading` | 🚫 強制關閉可能造成裝置永久損毀 | 🚫 Force-close may permanently damage the device |
| `control.firmwareBlock.description` | {deviceName} 的韌體{operation}正在進行中。現在強制關閉應用程式會中斷韌體寫入、可能造成裝置永久損毀(brick)、無法救援。 | {operation} on {deviceName} is in progress. Force-closing the application now will interrupt the firmware write and may permanently damage (brick) the device beyond recovery. |
| `control.firmwareBlock.operation.upgrade` | 升級 | upgrade |
| `control.firmwareBlock.operation.switch` | 切換 | switch |
| `control.firmwareBlock.stage` | 目前階段:{stage}(階段 {n} / {total})| Current stage: {stage} (stage {n} / {total}) |
| `control.firmwareBlock.eta` | 預估剩餘:約 {seconds} 秒 | Estimated remaining: ~{seconds}s |
| `control.firmwareBlock.recommendation` | ⚠ 建議「繼續等待」、約 {seconds} 秒後即可安全關閉。 | ⚠ Recommended: "Continue waiting". Safe to close in about {seconds}s. |
| `control.firmwareBlock.action.continueWaiting` | 繼續等待 | Continue waiting |
| `control.firmwareBlock.action.forceClose` | 強制關閉 | Force close |
| `control.firmwareBlock.confirm.title` | 🚫 確定要強制關閉? | 🚫 Confirm force close? |
| `control.firmwareBlock.confirm.risk.interrupt` | 中斷正在進行的韌體寫入 | Interrupt the ongoing firmware write |
| `control.firmwareBlock.confirm.risk.brick` | 可能造成裝置永久損毀(無法救援) | May permanently damage the device (unrecoverable) |
| `control.firmwareBlock.confirm.risk.noRecover` | 即使重新插拔也無法復原 | Cannot be recovered even by unplugging and reconnecting |
| `control.firmwareBlock.confirm.prompt` | 請輸入「FORCE」確認你了解後果: | Type "FORCE" to confirm you understand the consequences: |
| `control.firmwareBlock.confirm.placeholder` | FORCE | FORCE |
| `control.firmwareBlock.confirm.helpText` | (大小寫需完全相同) | (case-sensitive) |
| `control.firmwareBlock.confirm.action.back` | 返回 | Back |
| `control.firmwareBlock.confirm.action.forceClose` | 確認強制關閉 | Confirm force close |
| `control.footer.closeWarningFirmwareActive` | 🚫 韌體更新中、關閉視窗可能造成裝置損毀 | 🚫 Firmware update in progress — closing window may damage device |
合計新增 **19 個 i18n keys**(zh-TW / en 各一套)。
### 6a.10 無障礙考量
| 項目 | 設計 |
|------|------|
| Modal 結構 | `role="alertdialog"`、screen reader 主動朗讀 |
| Focus management | modal 開啟時自動 focus 「繼續等待」按鈕、focus trap |
| Tab 順序 | 「繼續等待」→「強制關閉」 |
| ESC 鍵 | 等同「繼續等待」(安全路徑)|
| 危險視覺不單靠顏色 | 標題附 ⚠ icon + 「強制關閉」按鈕附文字描述(不只是紅色) |
| Reduced motion | `prefers-reduced-motion: reduce` → modal 動畫 0ms、進度條改靜態顯示 |
| 觸控目標 | 兩個按鈕 ≥ 44×44px |
| 對比 | 紅色 destructive 對 card 背景 ≥ 4.5:1(WCAG AA、critical 信號不妥協)|
### 6a.11 與 Architect TDD §8.6 的銜接
- TDD §8.6 提供 server 端 `firmware.Service.HasActiveTask()` method 與 SIGTERM handler firmware-aware graceful 流程
- **韌體進行中偵測(Wails close handler 端、實作 §8.6.2)**:
- `OnBeforeClose` hook 觸發後、Wails 後端呼叫 `queryFirmwareActiveTasks()`、以 HTTP GET `http://127.0.0.1:{serverPort}/api/firmware/active-tasks`(1s timeout、fail-open)取得 active task 清單
- 有 active task → Wails 後端透過 `wailsRuntime.EventsEmit(ctx, "app:firmware-in-progress", payload)` 推送事件給前端
- 前端 listen `app:firmware-in-progress`、payload `{ hasActive: true, tasks: [{ taskId, deviceId, deviceName, chip, direction, stage, elapsedMs, etaSeconds }] }` → 渲染本節 6a.3 攔截 modal
- **「強制關閉」確認後(實作 §8.6.2 第 6 點)**:
- 前端輸入「FORCE」通過 → 呼叫 Wails binding `window.go.main.App.ConfirmForceClose()`
- 後端在 `firmwareCloseGuard` 設 `forceCloseAccepted=true`(mu 保護、race-free)、再以 goroutine 觸發 `wailsRuntime.Quit(ctx)`
- 下一輪 `OnBeforeClose` 被呼叫時、guard 看到旗標已設 → return `preventClose=false` 放行
- Wails 進入 `OnShutdown`、呼叫既有 `ctrl.Stop()` 走 R5-2 graceful(7+1s)path、server 端進入 SIGTERM handler、`AwaitActiveTasksOrTimeout(180s)` 雙層防護收尾
- **不採 SIGKILL bypass**:理由詳見 §6a.5(給 firmware task 一個收尾機會、降低 brick 機率)
- **fail-open 設計**:query active-tasks 失敗(server 沒起來 / 1s timeout / malformed JSON 等)→ 視為「無 active task」放行、靠 server 端 SIGTERM handler 雙層擋。Wails 層 false negative 不直接造成 brick、但使用者會看不到攔截 modal、改由 server 端在 system room broadcast `server:shutdown-pending` 事件通知前端(M8-5 frontend 接 event 顯示 toast)
- 詳細實作對齊 TDD §8.6.1–§8.6.5
---
## 7. 啟動行為(對應 R5-4 / R5-D3 / R5-E)
### 7.1 預設流程(v2.1 修訂)
**v2.1 重要變更**:Starting 狀態下控制台顯示**階段化啟動進度面板**(見 `v2/startup-progress.md`),不是只有一顆 spinner。下述「step」對應進度面板的 6 個階段。
```
1. 使用者雙擊 visionA-local.app
↓
2. 控制台視窗開(螢幕中央 / 上次位置)
↓
3. 控制台進入 Starting 狀態,log panel 上方顯示「啟動進度面板」
→ 階段 1:初始化控制台
→ 階段 2:檢查 Python runtime 與驅動
→ 階段 3:啟動本機伺服器(等 /api/health 200)
→ 階段 4:偵測 Kneron 裝置
↓
4. Server ready(階段 3 完成訊號 = /api/health 200)
↓
5. 【每次 / Settings 為 ON(macOS/Windows 預設;Linux 預設 OFF)】
階段 5「開啟瀏覽器」觸發 OS open browser
- macOS: `open http://127.0.0.1:3721/`
- Windows: `start http://127.0.0.1:3721/`
- Linux: `xdg-open http://127.0.0.1:3721/`
【Toggle OFF 時】階段 5 標記為「跳過(依偏好設定)」,不執行 OS open,但仍推進
↓
6. 階段 6:等待 Web UI 連線
(等 WebSocket hub 收到第一個 client 連線,R5-E6 決策)
【Toggle OFF 時】此階段改為「等待使用者手動點擊『在瀏覽器開啟』」
↓
7. 所有 6 階段完成
→ 啟動進度面板淡出(fade-out 200 ms)
→ Status: Running
→ Status text 顯示 `Running · Browser opened` 10 秒(Toggle ON 時)
或純 `Running`(Toggle OFF 時,使用者尚未手動 Open)
控制台留在背景(不最小化、不關閉)
↓
8. 瀏覽器 tab 進入 Next.js First-Run wizard(見 v2.4)
```
**R5-D3 重點**:**每次**啟動(每次 Wails App process 新啟動)都會跑完整 6 階段流程並觸發 OS open,不是只有首次。**Restart Server**(同一個 Wails process 內重啟 server)不會重開瀏覽器 tab — 由 Offline Overlay 的自動重連處理(見 `v2/server-offline-overlay.md`)。
### 7.2 視覺回饋
第 6 步的 `Running · Browser opened` 是使用者看到控制台第一個確認 server 就緒的訊號。具體視覺:
- Status dot 綠色
- Status text 後方 fade-in 一個 ✓ icon(`color.success`)
- Text 改為 `Running · Browser opened`
- 10 秒後 ✓ icon 淡出,text 縮為 `Running`
### 7.3 例外情境
| 情境 | 控制台行為 |
|------|----------|
| Server `Starting` 超過 5 秒 | 進入 Error state(見 §6),**不開瀏覽器** |
| Port 3721 被佔 | Server fallback 到 3722 / 3723,Header 顯示 `Port: 3722 (default 3721 in use)`,瀏覽器開的 URL 同步換 |
| Settings「自動開瀏覽器」= OFF | Server `Running` 後不做 auto-open,使用者需手動點 `Open in Browser` |
---
## 8. 深色模式處理
控制台深色模式**與 Web UI 同步**,機制:
- 讀取 OS 偏好:控制台是 Wails WebView,直接用 CSS `prefers-color-scheme`
- CSS 變數切換:和 `frontend/src/app/globals.css` 用一樣的 `:root` / `[data-theme='dark']` block
- 不提供手動切換(v1 決策延續)
**Dark 下額外考量**:
- Log panel 背景 `oklch(0.18 0 0)`(比 surface 再暗 5%,模仿 terminal)
- ERROR 紅色在 dark 下用 `oklch(0.72 0.19 25)`(避免過亮刺眼)
- 圓點狀態色全部用 dark variant,確保 4.5:1 對比(R4-3 降為盡力而為,但狀態色這種 critical 信號仍維持嚴格)
---
## 9. i18n key 清單(新元件)
控制台文字走 `desktop-control` namespace,**獨立於** Next.js Web UI 的 i18n 檔(但抽自同一份辭典,避免兩處維護)。
### 9.1 新增 key(zh-TW / en)
| Key | zh-TW | en |
|-----|-------|----|
| `control.title` | visionA-local · 伺服器控制台 | visionA-local · Server Control |
| `control.status.starting` | 啟動中... | Starting... |
| `control.status.running` | 執行中 | Running |
| `control.status.runningBrowserOpened` | 執行中 · 已開啟瀏覽器 | Running · Browser opened |
| `control.status.stopping` | 停止中... | Stopping... |
| `control.status.stopped` | 已停止 | Stopped |
| `control.status.error` | 錯誤:{reason} | Error: {reason} |
| `control.meta.port` | 連接埠 | Port |
| `control.meta.portFallback` | 連接埠:{port}(預設 {default} 被佔用) | Port: {port} (default {default} in use) |
| `control.meta.uptime` | 執行時間 | Uptime |
| `control.meta.pid` | 程序 ID | PID |
| `control.meta.version` | 版本 | Version |
| `control.action.openBrowser` | 在瀏覽器開啟 | Open in Browser |
| `control.action.start` | 啟動 | Start |
| `control.action.manage` | 管理 | Manage |
| `control.action.stopServer` | 停止伺服器 | Stop server |
| `control.action.restartServer` | 重新啟動伺服器 | Restart server |
| `control.log.followTail` | 自動跟隨最新 | Follow tail |
| `control.log.showTimestamps` | 顯示時間戳 | Show timestamps |
| `control.log.filterPlaceholder` | 過濾 log... | Filter... |
| `control.log.jumpToLatest` | 跳到最新 | Jump to latest |
| `control.log.clear` | 清空 | Clear |
| `control.log.clearToast` | 已清空 log(可復原) | Log cleared (undo) |
| `control.log.copy` | 複製 | Copy |
| `control.log.copyPrivacyHint` | Log 可能包含檔名與裝置資訊,請注意分享對象 | Log may contain filenames and device info. Share with care. |
| `control.log.export` | 匯出 log | Export log |
| `control.log.openFolder` | 開啟 log 資料夾 | Open log folder |
| `control.log.lines` | 行數:{current} / {max} | Lines: {current} / {max} |
| `control.footer.closeWarning` | ⚠ 關閉此視窗會停止伺服器 | ⚠ Closing this window will stop the server |
| `control.error.title` | 伺服器無法啟動 | Server failed to start |
| `control.error.description` | {具體原因} | {reason} |
| `control.error.restartButton` | 重新啟動伺服器 | Restart Server |
| `control.error.viewLogDetails` | 檢視 log 詳情 | View log details |
| `control.error.reportButton` | 回報問題... | Report... |
### 9.2 刪除 key(從現有 Next.js i18n 砍)
見 `v2/source-selector-update.md §3.2`。
---
## 10. 無障礙考量
| 項目 | 設計 |
|------|------|
| Keyboard navigation | 所有 interactive 元素 `tabindex` 合理序列:Open in Browser → Start/Manage → Follow tail → Show timestamps → Filter → (log panel 可選取) → Clear → Copy → Export → Open folder |
| Focus ring | 沿用 Web UI token `ring.2 · color.primary`,2 px outline-offset |
| Keyboard shortcut | `⌘F` / `Ctrl+F` 聚焦 filter;`⌘C` / `Ctrl+C` 複製選取 log;`⌘W` / `Ctrl+W` 關視窗(R5-2:結束 server) |
| `⌘Q` | macOS 原生結束 app:停 server + quit |
| Screen reader | Status indicator ``;Error banner ``;log panel `