jim800121chen a6cd1c12b2 fix(local-tool): Wails 控制台一打開就看到 modal — CSS specificity bug
使用者回報:Windows 乾淨環境安裝後,一打開 app 就看到「Settings modal +
shutdown-modal「正在停止伺服器…」+ 紅 banner「伺服器無法啟動」」三個應該
hidden 的 element 同時可見。

前面幾個 commit 一直往 Go 端找為什麼 ctrl.Stop 會被意外呼叫,全都沒對。
真正的 bug 是 CSS specificity:

  .modal-backdrop { display: flex; ... }  /* L587,specificity (0,1,0) */
  .error-banner   { display: flex; ... }  /* L488,specificity (0,1,0) */

這兩個 class 的 `display: flex` 規則和 user agent stylesheet 內建的
`[hidden] { display: none }` specificity 相同,但因為我們的 CSS 寫在
cascade 後段勝出——結果是即使 DOM 裡元素有 `hidden` 屬性,瀏覽器依然
渲染成 `display: flex` 可見。

三個受害元素:
  <div class="modal-backdrop" id="settings-modal" hidden>
  <div class="modal-backdrop shutdown-modal" id="shutdown-modal" hidden>
  <section class="error-banner" id="error-banner" hidden>

全部從 DOM 載入第一刻就可見,和 Go 端 ctrl.Stop 是否被呼叫無關。M7
splash 時代前端沒 modal 所以沒人踩到,M8 新加的控制台 UI(8cd5751)
引入這個 bug,但 macOS dev 測試時我只看 server 端 log + api 回應,
沒真的看 Wails 視窗長什麼樣,所以也漏抓。

修法:加全域 `[hidden] { display: none !important; }`。這是 W3C 規範
的標準寫法,保證任何帶 hidden 屬性的元素都會被隱藏,不管其他 CSS
規則怎麼寫。!important 在這情境是正確的——hidden 屬性代表「該元素
不應被顯示」是規範強制語意,不該被任何樣式覆蓋。

驗證:
- macOS dmg 重 build 163MB OK
- binary 內 strings 確認 `[hidden] { display: none !important; }` 已 embed
- 清乾淨 user dataDir 後啟動 wails app,wails.log 整條 startup 流程正常:
  Stage 1 complete → Stage 2 → ctrl.Start returned successfully
- Chrome 建立 2 條 ESTABLISHED 連線到 127.0.0.1:3721
- dataDir 有完整檔案(lock / ipc-port / wails-ipc-port / sentinel / models.json / nef/)

前幾個 commit 修的東西(Stage 2 pause、waitHealthy pause、shutdown modal
safety net、Bug A killStaleServerOnPort)仍然有防禦價值,但都不是使用者
截圖症狀的 root cause。

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 23:20:33 +08:00

702 lines
19 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* visionA-local 控制台 — Design Spec v2.1 對齊
* 設計 tokens 參考 shadcn oklch tokens近似 */
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
/* ---------- Design Tokens ---------- */
:root {
--bg: #ffffff;
--surface-1: #fafafa;
--surface-2: #f4f4f5;
--fg: #111827;
--fg-muted: #6b7280;
--border: #e5e7eb;
--border-strong: #d1d5db;
--primary: #2563eb;
--primary-fg: #ffffff;
--primary-hover: #1d4ed8;
--success: #16a34a;
--warning: #b45309;
--destructive: #b91c1c;
--destructive-soft:#fef2f2;
--focus-ring: rgba(37, 99, 235, 0.35);
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei',
'PingFang TC', 'Helvetica Neue', Arial, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Consolas', 'Roboto Mono', monospace;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow-md: 0 4px 12px rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f0f10;
--surface-1: #17181a;
--surface-2: #1f2022;
--fg: #e5e7eb;
--fg-muted: #9ca3af;
--border: #2a2b2e;
--border-strong: #3a3b3e;
--primary: #3b82f6;
--primary-fg: #ffffff;
--primary-hover: #2563eb;
--success: #22c55e;
--warning: #fbbf24;
--destructive: #f87171;
--destructive-soft:#2a1414;
--focus-ring: rgba(59, 130, 246, 0.45);
--shadow-sm: 0 1px 2px rgba(0,0,0,0.4);
--shadow-md: 0 4px 16px rgba(0,0,0,0.55);
}
}
html, body {
background: var(--bg);
color: var(--fg);
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
height: 100vh;
overflow: hidden;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
/* ---------- Layout ---------- */
.control-panel {
display: flex;
flex-direction: column;
height: 100vh;
min-width: 560px;
}
/* ---------- Header ---------- */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.brand-logo {
width: 40px;
height: 40px;
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.brand-info { flex: 1; min-width: 0; }
.brand-name {
margin: 0;
font-size: 16px;
font-weight: 600;
letter-spacing: -0.01em;
}
.status-line {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
font-size: 14px;
}
.status-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--fg-muted);
transition: background 300ms ease-out;
}
.status-dot.state-starting { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
.status-dot.state-running { background: var(--success); }
.status-dot.state-stopping { background: var(--warning); animation: pulse 1s ease-in-out infinite; }
.status-dot.state-stopped { background: var(--fg-muted); }
.status-dot.state-idle { background: var(--fg-muted); }
.status-dot.state-error { background: var(--destructive); }
.status-text {
font-weight: 500;
color: var(--fg);
}
.server-meta {
display: flex;
gap: 16px;
margin: 6px 0 0;
padding: 0;
font-size: 12px;
color: var(--fg-muted);
}
.server-meta .meta-item { display: flex; gap: 4px; }
.server-meta dt { display: inline; }
.server-meta dt::after { content: ':'; margin-right: 2px; }
.server-meta dd { display: inline; margin: 0; font-variant-numeric: tabular-nums; }
.brand-version {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
font-size: 12px;
color: var(--fg-muted);
}
.icon-btn {
background: transparent;
border: 1px solid transparent;
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--fg-muted);
font-size: 16px;
}
.icon-btn:hover { background: var(--surface-2); color: var(--fg); }
/* ---------- Primary controls ---------- */
.primary-controls {
display: flex;
gap: 8px;
padding: 10px 16px;
border-bottom: 1px solid var(--border);
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-md);
font-family: inherit;
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
line-height: 1;
min-height: 36px;
transition: background 120ms, border-color 120ms, color 120ms;
}
.btn:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
.btn[disabled] { cursor: not-allowed; opacity: 0.45; }
.btn-sm { padding: 4px 10px; font-size: 12px; min-height: 28px; }
.btn-primary {
background: var(--primary);
color: var(--primary-fg);
border-color: var(--primary);
}
.btn-primary:hover:not([disabled]) { background: var(--primary-hover); border-color: var(--primary-hover); }
/* Stage 6 manual hint → 引導使用者點擊 Open in Browser
* 對齊 Design Spec v2.1 startup-progress.md §4.1 */
.btn.pulse-cta:not([disabled]) {
animation: ctaPulse 1.8s ease-in-out infinite;
box-shadow: 0 0 0 0 var(--focus-ring);
}
@keyframes ctaPulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.55);
}
50% {
box-shadow: 0 0 0 8px rgba(37, 99, 235, 0);
}
}
@media (prefers-color-scheme: dark) {
@keyframes ctaPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.65); }
50% { box-shadow: 0 0 0 8px rgba(59, 130, 246, 0); }
}
}
/* Reduced motion改為靜態高亮外框不做動畫 */
@media (prefers-reduced-motion: reduce) {
.btn.pulse-cta:not([disabled]) {
animation: none;
outline: 2px solid var(--focus-ring);
outline-offset: 2px;
}
}
.btn-outline {
background: transparent;
color: var(--fg);
border-color: var(--border-strong);
}
.btn-outline:hover:not([disabled]) { background: var(--surface-2); }
.btn-ghost {
background: transparent;
color: var(--fg);
border-color: transparent;
}
.btn-ghost:hover:not([disabled]) { background: var(--surface-2); }
/* Manage dropdown */
.manage-wrapper { position: relative; }
.manage-menu {
position: absolute;
top: calc(100% + 4px);
right: 0;
min-width: 200px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px;
box-shadow: var(--shadow-md);
z-index: 10;
}
.menu-item {
display: block;
width: 100%;
text-align: left;
padding: 8px 12px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--fg);
cursor: pointer;
}
.menu-item:hover:not([disabled]) { background: var(--surface-2); }
.menu-item-danger { color: var(--destructive); }
.menu-item[disabled] { opacity: 0.4; cursor: not-allowed; }
.menu-divider {
height: 1px;
background: var(--border);
margin: 4px 0;
}
/* ---------- Log controls ---------- */
.log-controls {
display: flex;
align-items: center;
gap: 16px;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
font-size: 12px;
color: var(--fg-muted);
}
.checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.filter-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 4px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 8px;
}
.filter-wrapper input {
flex: 1;
border: none;
outline: none;
background: transparent;
color: var(--fg);
font-family: inherit;
font-size: 12px;
}
#level-filter {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 4px 8px;
color: var(--fg);
font-family: inherit;
font-size: 12px;
}
/* ---------- Startup panel ---------- */
.startup-panel {
margin: 12px 16px 0;
padding: 16px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
animation: fadeIn 200ms ease-out;
}
.startup-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
}
.stages {
display: flex;
flex-direction: column;
gap: 8px;
}
.stage-item {
display: grid;
grid-template-columns: 24px 24px 1fr auto;
column-gap: 8px;
row-gap: 2px;
align-items: center;
padding: 4px 0;
transition: opacity 200ms;
}
.stage-item[data-state="pending"] { opacity: 0.6; }
.stage-item[data-state="completed"] { opacity: 0.75; }
.stage-item[data-state="failed"] { background: rgba(185, 28, 28, 0.06); border-radius: var(--radius-sm); padding: 6px 8px; }
.stage-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
font-size: 14px;
color: var(--fg-muted);
}
.stage-item[data-state="running"] .stage-icon,
.stage-item[data-state="running-slow"] .stage-icon { color: var(--primary); }
.stage-item[data-state="completed"] .stage-icon { color: var(--success); }
.stage-item[data-state="failed"] .stage-icon { color: var(--destructive); }
.stage-number {
font-size: 13px;
font-weight: 500;
color: var(--fg-muted);
}
.stage-label-primary {
font-size: 13px;
font-weight: 500;
color: var(--fg);
}
.stage-item[data-state="completed"] .stage-label-primary { color: var(--fg-muted); }
.stage-item[data-state="failed"] .stage-label-primary { color: var(--destructive); font-weight: 600; }
.stage-label-secondary {
font-size: 11px;
color: var(--fg-muted);
}
.stage-status {
font-size: 12px;
color: var(--fg-muted);
justify-self: end;
}
.stage-item[data-state="running"] .stage-status { color: var(--primary); }
.stage-item[data-state="completed"] .stage-status { color: var(--success); }
.stage-item[data-state="failed"] .stage-status { color: var(--destructive); }
.stage-hint {
grid-column: 3 / 5;
font-size: 11px;
color: var(--warning);
margin-top: 2px;
}
.stage-hint::before { content: '⚠ '; }
/* Spinner */
.spinner-sm {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(0,0,0,0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
.spinner-lg {
display: inline-block;
width: 32px;
height: 32px;
border: 3px solid rgba(0,0,0,0.1);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.9s linear infinite;
}
@media (prefers-color-scheme: dark) {
.spinner-sm, .spinner-lg { border-color: rgba(255,255,255,0.12); border-top-color: var(--primary); }
}
.progress-row {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
}
.progress-bar {
flex: 1;
display: flex;
gap: 2px;
height: 6px;
}
.progress-cell {
flex: 1;
height: 6px;
background: var(--border);
border-radius: 2px;
transition: background 250ms;
}
.progress-cell.state-completed, .progress-cell.state-done, .progress-cell.state-skipped {
background: var(--success);
}
.progress-cell.state-running {
background: var(--primary);
animation: pulse 1.5s ease-in-out infinite;
}
.progress-cell.state-failed { background: var(--destructive); }
.progress-text {
font-size: 11px;
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Startup error mode */
.startup-error {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.error-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: var(--destructive);
}
.error-desc, .error-stage {
margin: 6px 0;
font-size: 12px;
color: var(--fg);
}
.error-actions {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
/* ---------- Runtime Error banner ---------- */
.error-banner {
display: flex;
gap: 12px;
margin: 12px 16px 0;
padding: 14px 16px;
background: var(--destructive-soft);
border: 1px solid var(--destructive);
border-radius: var(--radius-md);
animation: fadeIn 200ms ease-out;
}
.banner-icon { font-size: 20px; color: var(--destructive); }
.banner-content { flex: 1; }
.banner-title { display: block; font-size: 14px; color: var(--destructive); }
.banner-desc { margin: 4px 0 8px; font-size: 13px; color: var(--fg); }
.banner-actions { display: flex; gap: 8px; }
/* ---------- Log panel ---------- */
.log-panel {
flex: 1;
min-height: 100px;
margin: 12px 16px 0;
position: relative;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
#log-output {
display: block;
height: 100%;
overflow-y: auto;
padding: 8px 12px;
font-family: var(--font-mono);
font-size: 12px;
line-height: 1.5;
color: var(--fg);
white-space: pre;
}
.log-line {
display: block;
white-space: pre-wrap;
word-break: break-all;
padding: 1px 0;
border-radius: 2px;
transition: background 200ms;
}
.log-line.flash { background: rgba(185, 28, 28, 0.2); }
.log-ts {
color: var(--fg-muted);
margin-right: 4px;
}
.log-level {
display: inline-block;
font-weight: 600;
width: 48px;
}
.log-line.level-debug .log-level, .log-line.level-debug .log-msg { color: #60a5fa; }
.log-line.level-info .log-level { color: var(--fg-muted); }
.log-line.level-warn .log-level, .log-line.level-warn .log-msg { color: var(--warning); }
.log-line.level-error .log-level, .log-line.level-error .log-msg { color: var(--destructive); }
.log-line.level-plain .log-level { display: none; }
.jump-latest {
position: absolute;
bottom: 12px;
right: 12px;
padding: 6px 12px;
font-size: 11px;
background: var(--primary);
color: var(--primary-fg);
border: none;
border-radius: 16px;
cursor: pointer;
box-shadow: var(--shadow-md);
}
/* ---------- Log actions ---------- */
.log-actions {
display: flex;
gap: 4px;
padding: 8px 16px;
}
/* ---------- Footer ---------- */
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 16px 8px;
font-size: 11px;
color: var(--fg-muted);
border-top: 1px solid var(--border);
}
.footer-right { color: var(--warning); }
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 150ms ease-out;
}
/* CSS specificity 修補:`.modal-backdrop` 的 `display: flex` 規則
* specificity = (0,1,0),和 user agent stylesheet 的 `[hidden] { display: none }`
* 相同,但因為寫在後面 cascade 勝出 → 結果是即使 div 有 `hidden` 屬性,
* modal 依然可見。這裡用 (0,2,0) 的規則強制 hidden 生效。
*
* 症狀M8 一開始就踩到但 M7 splash 沒 modal 所以沒人抓到):
* Wails 控制台一打開就同時看到 Settings modal + shutdown-modal 疊在畫面上,
* 即使 DOM 裡兩個都有 `hidden` 屬性。
*
* 同樣問題也影響 `.error-banner`display: flex 覆蓋 hidden所以紅 banner
* 「伺服器無法啟動」也會一開 app 就可見。下面的通用 `[hidden]` 全域規則處理
* 這兩個和未來任何 `.class { display: X }` 被 `hidden` 屬性覆蓋的情況。
*
* 用 !important 是必要的——attribute selector specificity `(0,1,0)` 和 class
* selector 相同,光靠 `.class[hidden]` 要重複寫每個 class。全域 `[hidden]!important`
* 最省事,且符合 HTML 規範「hidden 屬性代表該元素不應被顯示」的語意。
*/
[hidden] { display: none !important; }
.modal {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
min-width: 380px;
max-width: 560px;
box-shadow: var(--shadow-md);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
}
.modal-header h2 { margin: 0; font-size: 15px; font-weight: 600; }
.modal-body { padding: 16px; }
.setting-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 10px 0;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.setting-row:last-of-type { border-bottom: none; }
.setting-label { font-size: 13px; font-weight: 500; }
.setting-hint { font-size: 11px; color: var(--fg-muted); margin-top: 2px; }
.setting-row input[type="checkbox"], .setting-row select {
font-family: inherit;
}
.about-section {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.about-section h3 { margin: 0 0 8px; font-size: 12px; font-weight: 600; color: var(--fg-muted); }
.about-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
font-size: 11px;
margin: 0;
}
.about-list dt { color: var(--fg-muted); }
.about-list dd { margin: 0; color: var(--fg); word-break: break-all; }
/* Shutdown modal */
.shutdown-modal .modal.shutdown-dialog {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 24px 40px;
min-width: 280px;
}
/* ---------- Toast ---------- */
.toast {
position: fixed;
bottom: 56px;
left: 50%;
transform: translateX(-50%);
padding: 10px 16px;
background: rgba(17, 24, 39, 0.92);
color: #fff;
border-radius: var(--radius-md);
font-size: 12px;
box-shadow: var(--shadow-md);
z-index: 200;
max-width: 80%;
word-break: break-all;
animation: fadeIn 150ms ease-out;
}
@media (prefers-color-scheme: dark) {
.toast { background: rgba(240, 240, 240, 0.95); color: #111; }
}
/* ---------- Animations ---------- */
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; } }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.spinner-sm, .spinner-lg { border: 2px solid var(--primary); border-radius: 50%; }
}