# Reviewer 審查 M8-5 兩個補丁(2026-04-15) ## 摘要 - **補丁 A(Stage 6 Manual CTA)**:✅ **通過**。`manualMode` 狀態機、`enterManualMode` 觸發、`onManualModeChange` pub-sub、stage 6 manual hint description、stage 5 skipped label、CTA pulse、dark / reduced-motion fallback 全部對齊 Design Spec v2.1 §4.1 §7。i18n 用既有 key 無新增。 - **補丁 B(ServerState + server:error payload)**:✅ **通過**。`control-panel.js` 匯出 6 個 lowercase 常數、`data-state` / dot class 不再 `.toLowerCase()`;`app.js` import `STATE_ERROR`、`server:error` 讀 `payload.reason` 並附註 Go source of truth 註解。 - **是否阻擋 M8-10**:**不阻擋**。M8-5 原 Reviewer 的 2 Critical 已完全修復,M8-4b Reviewer 提醒的 stage 6 manual CTA UX 也補齊。全部 4 個改過的 JS `node --check` 乾淨;`wails build -s -m -skipbindings` 產出 `build/bin/visiona-local.app`(6.272s)。Minor 層級問題均為 Suggestion。 --- ## A. Stage 6 Manual CTA(補丁 A) ### A1. manualMode 狀態機 | 檢查項 | 結果 | 備註 | |---|---|---| | `enterManualMode()` 在 stage=5 status="skipped" 時觸發 | ✅ | `startup-panel.js:182-184`「`if (n === 5 && stages[5].status === 'skipped') enterManualMode()`」 | | `manualMode` toggle 正確 | ✅ | `startup-panel.js:16` module-scope flag;`enterManualMode` L216 幂等(若已進入直接 return)→ L217 setTrue;`hideStartupPanel` L163-166 reset;stage 6 completed L187-193 reset | | `manualModeListeners` pub-sub | ✅ | L18 `Set`;L19-22 `onManualModeChange(fn)` 回傳 unsubscribe;L23-27 `emitManualMode` try/catch 保護 listener 拋錯 | | `hideStartupPanel` reset 清 manualHint | ✅ | L160-162 重置 stages[1..6](含 `manualHint: false`),L163-166 若 `manualMode===true` 則 toggle 並 emit false | ### A2. stage 6 manualHint description | 檢查項 | 結果 | 備註 | |---|---|---| | `paintStageRow` 偵測 `stage===6 && manualHint` | ✅ | `startup-panel.js:88-89`「`else if (stage === 6 && st.manualHint) labelSecondary.textContent = t('startup.stage.6.manualHint')`」 | | `stage===5 && status==='skipped'` → `startup.stage.5.skipped.label` | ✅ | L86-87 | | `markStageTimeout` manualMode + stage 6 時忽略 soft timeout | ✅ | L242「`if (n === 6 && stages[6].manualHint) return`」;與 Design §4.1「不套 20 秒 retry hint」一致 | | `paintStageRow` 的 slow hint 也避開 manual hint | ✅ | L107「`if (st.slow && st.status === 'running' && !st.manualHint)`」雙重保險 | ### A3. i18n key 使用 | 檢查項 | 結果 | 備註 | |---|---|---| | 無新增 key | ✅ | 全用既有 3 個:`startup.stage.6.manualHint` / `startup.stage.5.skipped.label` / `startup.status.skipped` | | 既有 key 存在雙語 | ✅ | `i18n.js:61,64,69`(zh-TW)+ `i18n.js:140,143,148`(en),與 Design §7 表格對齊 | ### A4. CTA pulse | 檢查項 | 結果 | 備註 | |---|---|---| | `setPrimaryCTAPulse(true/false)` add/remove class | ✅ | `control-panel.js:105-113`;取 `#btn-open-browser` 後 `classList.add/remove('pulse-cta')` | | app.js 訂閱 `onManualModeChange` → 驅動 pulse | ✅ | `app.js:32-33` import;`app.js:42` import `onManualModeChange`;`app.js:134-136` init step 11 `onManualModeChange((enabled) => setPrimaryCTAPulse(enabled))` | | `.pulse-cta` CSS 正確 | ✅ | `style.css:205-216` `@keyframes ctaPulse` 1.8s ease-in-out infinite,box-shadow 0px→8px 漸散(符合 material-style 脈衝) | | `:not([disabled])` 選擇器 | ✅ | `style.css:205` `.btn.pulse-cta:not([disabled])` — 按鈕 disabled 時不做動畫(避免 stage 5 skipped 但 server state 還沒到 running 的極短窗口裡 pulse disabled 按鈕) | | dark mode 變體 | ✅ | `style.css:217-222` `@media (prefers-color-scheme: dark)` 覆寫 `@keyframes ctaPulse` 為較亮藍(`rgba(59,130,246,0.65)`) | | `prefers-reduced-motion` fallback | ✅ | `style.css:224-230` 動畫關閉,改 `outline: 2px solid var(--focus-ring)`;L676-682 全域 override 會把 animation-duration 壓成 0.01ms,兩層不衝突,outline 仍會套用 | ### A5. stage 6 completed 離開 manualMode 路徑驗證(親自追 Go → 前端): 1. 使用者按 Open in Browser → `OpenInBrowser` binding → 瀏覽器載入 Web UI 2. 瀏覽器 WebSocket 連線 → Hub register channel → `hub.go:112` `writeStartupSentinel`(`sync.Once`) 3. `startup_pipeline.go:313-318` watcher 每秒檢查 sentinel 檔案 → `CompleteStage(6)` 4. `startup_pipeline.go:230-251` `emitProgress` goroutine → Wails event `startup:progress {stage:6, status:"completed"}` 5. `app.js:318-324` `EventsOn('startup:progress')` → `updateStage(ev)` 6. `startup-panel.js:187-193` 條件 `n === 6 && (status === 'completed' || 'done')` → `manualMode=false` + `stages[6].manualHint=false` + `emitManualMode(false)` 7. `app.js:134-136` listener → `setPrimaryCTAPulse(false)` → classList.remove 8. 接著 `startup_pipeline.go:223` `markReady` emit `startup:ready` → `app.js:331` → `hideStartupPanel()`(二次保險) ✅ 路徑完整,pulse 在 stage 6 completed 瞬間即停,不等 panel unmount。 ### A6. Race 保護 **情境 1:stage 5 skipped 先到,stage 6 running 後到** - L182-184 `enterManualMode()` → L219 `stages[6].status === 'pending'` 為 true → 主動設 `running` + `startedAt` - 之後 Go 層送 stage 6 running,L175 `stages[n].status = ev.status` 覆蓋為 `running`(同值),**不動 `manualHint`**(L175 只改 status) - `paintStageRow(6)` 仍看到 `st.manualHint === true` → 顯示 manual hint description ✅ **情境 2:stage 6 running 先到,stage 5 skipped 後到** - 第一個 event:`updateStage` L175 `stages[6].status = 'running'`;L182 條件 `n===5 && ...` 不符合 → 不 enter manual mode - 第二個 event:`updateStage` L175 `stages[5].status = 'skipped'`;L182 條件符合 → `enterManualMode()`;L219 `stages[6].status === 'pending'` 為 **false**(已是 running),不重設 startedAt,但 L223 `stages[6].manualHint = true` + L225 `stages[6].slow = false` + L226 paint → manual hint 顯示 ✅ 兩種 race 都正確。 **情境 3:stage 6 running 與 stage 5 skipped 極短時間內雙飛** - Go emitProgress 用 goroutine(`startup_pipeline.go:230-251`),理論上可能亂序;但 Wails EventsEmit 單一 JS runtime 接收為序列 queue,兩個 callback 接續執行,兩種順序均已驗證 ✅ ### A 小結 補丁 A 全部通過。`manualMode` 狀態機、pub-sub、stage 5/6 label 分流、CTA pulse、dark/reduced-motion 完整對齊 Design Spec v2.1 §4.1 §7,無 Critical / Major 問題。 --- ## B. Critical 修復(補丁 B) ### B1. ServerState lowercase 統一 | 檢查項 | 結果 | 備註 | |---|---|---| | `control-panel.js` 常數全 lowercase | ✅ | L7-12 定義 6 個常數(`idle / starting / running / stopping / stopped / error`),對齊 Go `server_control.go:44-49` | | 匯出給其他檔 import | ✅ | L7-12 每行都 `export const`;`app.js:33` `import { STATE_ERROR } from './control-panel.js'` | | 內部比對用常數 | ✅ | L20 `STATE_IDLE`;L26-34 switch case;L55 `STATE_RUNNING`;L82-86 primary controls | | grep 無 PascalCase state 殘留 | ✅ | 僅 `i18n.js:87,89,92,145` 是顯示用 label value(`'Idle'` / `'Running'` / `'Stopped'`),非 state 比對,符合預期 | | `control-panel.js` 無 `.toLowerCase()` | ✅ | grep 零匹配 | ### B2. data-state 屬性 + dot class | 檢查項 | 結果 | 備註 | |---|---|---| | 不再 `.toLowerCase()` | ✅ | L21 `root.setAttribute('data-state', s)`(既然 `s` 已是 lowercase);L22 `dot.className = 'status-dot state-' + s` | | CSS 選擇器匹配 | ✅ | 快速檢查 `style.css` 的 `.status-dot.state-*` 與 `[data-state="*"]` 應全 lowercase;原 M8-5 Review 已驗過 dot 顯示正常(因當時有 `.toLowerCase()` hack),現在直接用 lowercase 字串,匹配相同且更乾淨 | ### B3. server:error payload.reason | 檢查項 | 結果 | 備註 | |---|---|---| | 改為 `payload.reason` | ✅ | `app.js:301-304`「`if (payload && payload.reason) showErrorBanner(payload.reason)`」 | | 註解說明 Go source of truth | ✅ | L302「`Go 端 server_control.go L184/L361 emit payload key 為 "reason"(不是 "error")`」 | | 親自核對 Go 端 | ✅ | `server_control.go:184-186` 確認 emit `map[string]any{"reason": err.Error()}`;未查 L361 但題目有註明 | ### B4. Stage 5 skipped 情境驗證(補丁 A + B 交集 — 最關鍵懸念) **M8-5 原 Reviewer 最重要的懸而未決:Open in Browser 按鈕在 stage 5 skipped 時是否 enabled?** 親自追 Go 程式碼: 1. 冷啟動 → `app.go:235` `a.ctrl.Start()` 2. `server_control.go:200` `c.setState(ServerStateRunning, "")` — **Start 成功後立刻進入 `running`** 3. Start 返回 → `app.go:242` `a.runStartupStage5()` 4. `app.go:253-255` `AutoOpenBrowser=false` → `a.startupPipeline.SkipStage(5)` **關鍵結論**:當前端收到 `startup:progress stage=5 status=skipped` 時,**ctrl.state 早已是 `running`**(在 Step 2 已 setState)。 前端路徑: - Wails `server:state-change` event 早一步送過 `state='running'` payload - `app.js:300` `handleServerStatus` → `updatePrimaryControls` → L82 `openBtn.disabled = s !== STATE_RUNNING = 'running' !== 'running' = false` - **Open in Browser 按鈕 enabled** ✅ - 同時 `startup:progress stage=5 skipped` 觸發 `enterManualMode` → pulse 啟動 - 使用者看到彈出 panel + pulse 的 Open in Browser 按鈕可點 → 體驗順暢 `.pulse-cta:not([disabled])` 選擇器也守住了邊界:即便極短時間窗口內 state 尚未到 running,disabled 按鈕也不會誤 pulse(視覺正確)。 **補丁 B 修復 Critical 1 之後,stage 5 skipped → stage 6 manual CTA 的完整流程成立。** B4 通過。 --- ## C. 親跑驗證 ``` == app.js == node --check OK == control-panel.js == node --check OK == startup-panel.js == node --check OK == i18n.js == node --check OK == style.css == (CSS, 跳過 node --check) ``` ``` wails build -s -m -skipbindings → Compiling application: Done. → Packaging application: Done. → Self-signing application: Done. → Built build/bin/visiona-local.app (6.272s) ``` `wails build` 成功產出 `.app`,證明 `go:embed all:frontend` 正確吃進 4 個修改後的前端檔。 **PascalCase state 殘留 grep**: ``` visiona-local/frontend/i18n.js:87: 'control.status.idle': 'Idle', visiona-local/frontend/i18n.js:89: 'control.status.running': 'Running', visiona-local/frontend/i18n.js:92: 'control.status.stopped': 'Stopped', visiona-local/frontend/i18n.js:145: 'startup.status.running': 'Running', ``` 僅 4 處,全是 i18n label 顯示文字(非 state 比對),符合題目允許範圍。 **`.toLowerCase()` 殘留 grep**:`control-panel.js` 零匹配。 --- ## D. 問題清單 ### Critical(阻擋 M8-10) (無) ### Major (無) ### Minor | # | 檔案:行 | 問題描述 | 建議 | |---|---|---|---| | m-1 | `style.css:209-222` dark variant 覆寫 `@keyframes ctaPulse` | 把整個 keyframes 重新定義,而非用 CSS variable。若未來想調 pulse 顏色需改兩處。 | 可改用 `--pulse-color` CSS var + dark `:root` override,但目前做法對齊既有 pattern(status-dot color variant 也是同法),*不強制修*。 | | m-2 | `startup-panel.js:182-184` 前端主動 enter manual mode | 依 Design §4.1 流程,Go 端理論上會先送 stage 5 skipped 再送 stage 6 running,但前端這裡先行 paint 並設 `stages[6].running`,若 Go 層之後沒送 stage 6 running(例如 pipeline 異常),manualHint 狀態永遠停在「前端自造」的 running。**目前 M8-4b 是必定送的**,故不會發生;屬防禦性建議。 | 可在 `emitManualMode(true)` 後加一個 watchdog(30 秒沒收到 stage 6 running 則 console.warn),但非必要。 | | m-3 | 既有 Minor 沿用 — `log-panel.js` footer lines 英文硬編 | M8-5 原 review 的 m-1,本輪補丁 A/B 未涵蓋,仍然存在 | 保留為 M8-10 前收斂的項目 | ### Suggestion | # | 檔案 | 建議 | |---|---|---| | s-1 | `startup-panel.js:16-27` pub-sub | 若未來 manualMode 觀察者增多,可考慮用 `EventTarget` 替代 `Set`,標準 API 且可 `dispatchEvent`。目前單一訂閱者,`Set` 已足夠。 | | s-2 | `control-panel.js:7-12` 常數 | 可再多 export 一個 `SERVER_STATES` frozen object 方便 iterate(如 `Object.values(SERVER_STATES)` 做 sanity check),非必要。 | | s-3 | `style.css:205` selector | `.btn.pulse-cta:not([disabled]))` 選擇器優雅,但如果未來 Open in Browser 按鈕換 class 名稱(非 `.btn`),pulse 會失效。可改成單純 `.pulse-cta:not([disabled])`。 | --- ## E. 結論 **審查結果:✅ 通過(第 1 輪)** - **補丁 A(Stage 6 Manual CTA)**:對齊 Design Spec v2.1 §4.1 §7,manualMode 狀態機完整、pub-sub 安全、i18n 用既有 key 雙語齊全、CTA pulse 加 dark / reduced-motion fallback。M8-4b Reviewer 提醒的「AutoOpenBrowser=false 時 panel 永遠不淡出」UX 已補齊。 - **補丁 B(ServerState + server:error payload)**:M8-5 原 Critical 1 / 2 完全修復。`control-panel.js` 全 lowercase 對齊 Go `server_control.go:44-49`;`app.js` 讀 `payload.reason` 對齊 `server_control.go:184`。 - **跨補丁交集 B4**:親自追 Go → 前端路徑驗證 stage 5 skipped 時 `ctrl.state` 已是 `running`,Open in Browser 按鈕 enabled,pulse 引導使用者點擊,流程順暢。 - **親跑**:4 個 JS `node --check` 全過;`wails build -s -m -skipbindings` 成功產出 `build/bin/visiona-local.app`(6.272s)。 **不阻擋 M8-10**。建議 Orchestrator 將本輪補丁 A/B 視為 M8-5 的最終交付。M8-5 原 review 的 Minor m-1~m-8 仍在 backlog(不阻擋),可由 M8-10 整理階段一併收斂。 **給 Frontend Agent 的正面回饋**: - manualMode pub-sub 用 `Set` + try/catch 保護 listener,簡潔且防禦性佳 - `enterManualMode` / `hideStartupPanel` 兩處 reset 對稱,狀態機閉環 - `.pulse-cta:not([disabled])` 選擇器與 Go state 時序巧妙搭配(避免 pulse 空按鈕) - CSS dark mode 與 `prefers-reduced-motion` 兩層無障礙 fallback 齊全 - B 補丁用 module-level 常數取代字面量 PascalCase,未來再也不會發生大小寫 drift