feat: light theme + system-following, with the dark theme preserved exactly
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled

BCC has always been dark-only -- BG #1b1d23, hardcoded at import time, with
no light option and no awareness of the desktop's appearance. On a light
desktop it matches nothing else on screen and there was no way to change it.

Adds a Palette value type in bcc_core with DARK (byte-identical to the
colours v1.3.0 shipped) and a new LIGHT, plus resolve_theme(setting,
system_is_dark) so the decision is testable without a Qt app. View > Theme
offers Match system / Light / Dark, persisted in QSettings under ui/theme,
defaulting to following the system.

The light palette's semantic colours are deliberately not the dark ones
lightened: #4ade80 sits near 1.7:1 against white. They are darkened to clear
WCAG AA, and a contrast test enforces >= 4.5:1 for every text colour against
its surface in both palettes so nobody harmonises them back later.

Three near-black literals were baked into the stylesheet (#1a1205 on-accent
text, #202229 disabled table, #16181d diagnostics pane). Fine with one theme,
invisible breakage with two -- each now has a palette slot, and a test
asserts build_stylesheet contains no hex literals at all.

The ~20 inline setStyleSheet(f"color: {MUTED}") call sites are left alone:
apply_palette rebinds the module-level colour names, and an f-string resolves
its names when it runs, so each call site picks up the new colour on its next
render. Switching theme reapplies the global QSS and re-renders the
inline-styled widgets, so nothing is left dark-on-light.

Refs #75
This commit is contained in:
2026-07-20 12:21:45 -04:00
parent cd2ac2f6f8
commit febd617c56
3 changed files with 397 additions and 51 deletions
+149 -51
View File
@@ -18,6 +18,7 @@ from typing import ClassVar
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import (
QAction,
QActionGroup,
QColor,
QCursor,
QDesktopServices,
@@ -25,6 +26,7 @@ from PySide6.QtGui import (
QIcon,
QKeySequence,
QPainter,
QPalette,
QPixmap,
)
from PySide6.QtWidgets import (
@@ -65,85 +67,118 @@ import bcc_core as core
# thread during drag-and-drop import, so skip anything larger than this.
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
# --- One-line rebrand: change this to recolor the whole app --------------- #
ACCENT = "#f97316" # warm orange
ACCENT_DIM = "#c2570b"
BG = "#1b1d23"
PANEL = "#23262e"
PANEL_2 = "#2b2f39"
TEXT = "#e7e9ee"
MUTED = "#9aa0ad"
BORDER = "#3a3f4b"
GOOD = "#4ade80"
BAD = "#f87171"
WARN = "#fbbf24"
# --- Theming (issue #75) -------------------------------------------------- #
# The palette lives in bcc_core (testable without a Qt app); these module-level
# names are rebound by `apply_palette()` whenever the theme changes.
#
# Why globals rather than passing a palette around: ~20 inline
# `setStyleSheet(f"color: {MUTED}")` calls are scattered through this file, and
# an f-string resolves its names when it runs, not when it's compiled. Rebinding
# the globals means every one of those call sites picks up the new colour on its
# next render, with no change to the call sites themselves.
PALETTE = core.DARK_PALETTE
ACCENT = ACCENT_DIM = BG = PANEL = PANEL_2 = TEXT = MUTED = BORDER = ""
GOOD = BAD = WARN = REMOTE = ON_ACCENT = DISABLED_BG = MONO_BG = SEL_TEXT = ""
STATUS_COLORS: dict[str, str] = {}
HEALTH_COLORS: dict[str, str] = {}
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN}
STATUS_GLYPH = {"ok": "", "missing": "", "warn": "", "remote": "", "unknown": ""}
STATUS_GLYPH = {
"ok": "\u25cf",
"missing": "\u25cf",
"warn": "\u25b2",
"remote": "\u25c6",
"unknown": "\u25cb",
}
# Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the
# server tables' "Health" column -- distinct from the PATH-dependency Status
# column above.
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
HEALTH_GLYPH = {"ok": "", "failed": "", "untested": ""}
HEALTH_GLYPH = {"ok": "\u25cf", "failed": "\u25cf", "untested": "\u25cb"}
STYLESHEET = f"""
def build_stylesheet(p: core.Palette) -> str:
"""Render the global QSS for a palette."""
return f"""
/* No font-family here on purpose: Qt already uses the native system UI font
on every platform (San Francisco / Segoe UI / desktop default). Naming
web-CSS aliases like -apple-system forces a costly font-alias scan. */
* {{ font-size: 13px; color: {TEXT}; }}
QMainWindow, QDialog {{ background: {BG}; }}
* {{ font-size: 13px; color: {p.text}; }}
QMainWindow, QDialog {{ background: {p.bg}; }}
QLabel#h1 {{ font-size: 15px; font-weight: 600; }}
QLabel#muted {{ color: {MUTED}; }}
QFrame#card {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; }}
QLabel#muted {{ color: {p.muted}; }}
QFrame#card {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px; }}
QLineEdit, QPlainTextEdit, QComboBox {{
background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px;
padding: 6px 8px; selection-background-color: {ACCENT}; selection-color: #1a1205;
background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px;
padding: 6px 8px; selection-background-color: {p.accent}; selection-color: {p.on_accent};
}}
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {ACCENT}; }}
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {p.accent}; }}
QComboBox::drop-down {{ border: none; width: 22px; }}
QComboBox QAbstractItemView {{ background: {PANEL_2}; border: 1px solid {BORDER};
selection-background-color: {ACCENT}; outline: none; }}
QPushButton {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px;
QComboBox QAbstractItemView {{ background: {p.panel_2}; border: 1px solid {p.border};
selection-background-color: {p.accent}; outline: none; }}
QPushButton {{ background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px;
padding: 7px 13px; }}
QPushButton:hover {{ border: 1px solid {ACCENT}; }}
QPushButton:disabled {{ color: {MUTED}; background: {PANEL}; }}
QPushButton#primary {{ background: {ACCENT}; border: 1px solid {ACCENT}; color: #1a1205; font-weight: 600; }}
QPushButton#primary:hover {{ background: {ACCENT_DIM}; }}
QPushButton#primary:disabled {{ background: {PANEL}; color: {MUTED}; border: 1px solid {BORDER}; }}
QPushButton#danger:hover {{ border: 1px solid {BAD}; color: {BAD}; }}
QTableWidget {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px;
QPushButton:hover {{ border: 1px solid {p.accent}; }}
QPushButton:disabled {{ color: {p.muted}; background: {p.panel}; }}
QPushButton#primary {{ background: {p.accent}; border: 1px solid {p.accent}; color: {p.on_accent}; font-weight: 600; }}
QPushButton#primary:hover {{ background: {p.accent_dim}; }}
QPushButton#primary:disabled {{ background: {p.panel}; color: {p.muted}; border: 1px solid {p.border}; }}
QPushButton#danger:hover {{ border: 1px solid {p.bad}; color: {p.bad}; }}
QTableWidget {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px;
gridline-color: transparent; outline: none; }}
QTableWidget::item {{ padding: 6px 8px; border: none; }}
QTableWidget::item:selected {{ background: {ACCENT}; color: #1a1205; }}
QTableWidget::item:selected {{ background: {p.accent}; color: {p.on_accent}; }}
/* Inline cell editors: the global QLineEdit padding/radius clips the text
inside a table row, so give editors a compact, flat style instead. */
QTableWidget QLineEdit {{
background: {PANEL_2}; color: {TEXT}; border: 1px solid {ACCENT};
background: {p.panel_2}; color: {p.text}; border: 1px solid {p.accent};
border-radius: 3px; padding: 0px 4px; margin: 0px;
selection-background-color: {ACCENT_DIM}; selection-color: #ffffff;
selection-background-color: {p.accent_dim}; selection-color: {p.selection_text};
}}
QHeaderView::section {{ background: {PANEL}; color: {MUTED}; border: none;
border-bottom: 1px solid {BORDER}; padding: 8px; font-weight: 600; }}
QHeaderView::section {{ background: {p.panel}; color: {p.muted}; border: none;
border-bottom: 1px solid {p.border}; padding: 8px; font-weight: 600; }}
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
QScrollBar::handle:vertical {{ background: {p.border}; border-radius: 5px; min-height: 24px; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }}
QTableWidget#disabledTable {{ background: #202229; }}
QTableWidget#disabledTable::item:selected {{ background: {ACCENT}; color: #1a1205; }}
QLabel#statusbar {{ color: {p.muted}; padding: 4px 2px; }}
QLabel#warnBanner {{ color: {p.on_accent}; background: {p.warn}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
QLabel#section {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#sectionDisabled {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#placeholder {{ color: {p.muted}; padding: 12px; background: {p.panel_2}; border: 1px dashed {p.border}; border-radius: 8px; }}
QTableWidget#disabledTable {{ background: {p.disabled_bg}; }}
QTableWidget#disabledTable::item:selected {{ background: {p.accent}; color: {p.on_accent}; }}
QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace;
font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }}
font-size: 12px; background: {p.mono_bg}; border: 1px solid {p.border}; border-radius: 8px; }}
QFrame#diagCard {{ background: transparent; border: none; }}
QSplitter::handle {{ background: transparent; }}
QSplitter::handle:hover {{ background: {BORDER}; border-radius: 4px; }}
QSplitter::handle:pressed {{ background: {ACCENT}; border-radius: 4px; }}
QSplitter::handle:hover {{ background: {p.border}; border-radius: 4px; }}
QSplitter::handle:pressed {{ background: {p.accent}; border-radius: 4px; }}
"""
def apply_palette(p: core.Palette) -> str:
"""Rebind the module-level colour names to `p` and return its stylesheet."""
global PALETTE, ACCENT, ACCENT_DIM, BG, PANEL, PANEL_2, TEXT, MUTED, BORDER
global GOOD, BAD, WARN, REMOTE, ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT
global STATUS_COLORS, HEALTH_COLORS
PALETTE = p
ACCENT, ACCENT_DIM = p.accent, p.accent_dim
BG, PANEL, PANEL_2 = p.bg, p.panel, p.panel_2
TEXT, MUTED, BORDER = p.text, p.muted, p.border
GOOD, BAD, WARN, REMOTE = p.good, p.bad, p.warn, p.remote
ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT = (
p.on_accent,
p.disabled_bg,
p.mono_bg,
p.selection_text,
)
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": REMOTE, "unknown": WARN}
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
return build_stylesheet(p)
STYLESHEET = apply_palette(core.DARK_PALETTE)
# --------------------------------------------------------------------------- #
# Background reachability tester (keeps the UI responsive during the request)
# --------------------------------------------------------------------------- #
@@ -1585,11 +1620,47 @@ class MainWindow(QMainWindow):
# --- menu bar ---------------------------------------------------------- #
def _build_menu_bar(self):
view_menu = self.menuBar().addMenu("&View")
theme_menu = view_menu.addMenu("Theme")
self._theme_group = QActionGroup(self)
self._theme_group.setExclusive(True)
current = stored_theme_setting()
for setting, label in (
(core.THEME_SYSTEM, "Match system"),
(core.THEME_LIGHT, "Light"),
(core.THEME_DARK, "Dark"),
):
act = QAction(label, self, checkable=True)
act.setChecked(setting == current)
act.triggered.connect(lambda _checked=False, s=setting: self._set_theme(s))
self._theme_group.addAction(act)
theme_menu.addAction(act)
help_menu = self.menuBar().addMenu("&Help")
about_action = QAction("About Better Claude Config…", self)
about_action.triggered.connect(self._show_about)
help_menu.addAction(about_action)
def _set_theme(self, setting: str):
"""Persist the theme choice and repaint the running window."""
QSettings("BCC", "BetterClaudeConfig").setValue("ui/theme", setting)
app = QApplication.instance()
if app is None: # pragma: no cover - only in a headless test harness
return
app.setStyleSheet(theme_stylesheet_for(app, setting))
# The global stylesheet covers most of the UI, but the inline
# setStyleSheet calls (status dots, warning labels, update banner) only
# pick up the new palette when their widget next renders -- so re-render
# them now rather than leaving dark-on-light text behind.
self._repaint_themed_widgets()
def _repaint_themed_widgets(self):
"""Re-run the inline-styled bits after a palette change."""
self.status.setStyleSheet(f"color: {MUTED};")
idx = self._current_index()
self._refresh_tables(select_index=idx if idx >= 0 else -1)
self._update_status(saved=False)
def _show_about(self):
AboutDialog(self).exec()
@@ -2649,6 +2720,33 @@ class MainWindow(QMainWindow):
e.accept()
def system_is_dark(app: QApplication) -> bool:
"""Whether the desktop is currently using a dark appearance.
Read from the style's own window colour rather than per-platform APIs --
Qt has already resolved the OS appearance by the time it builds the
default palette, so this works the same on all three platforms.
"""
try:
return app.palette().color(QPalette.ColorRole.Window).lightness() < 128
except Exception: # pragma: no cover - defensive; never block startup on theming
return True
def stored_theme_setting() -> str:
"""The user's theme choice, defaulting to following the system."""
value = QSettings("BCC", "BetterClaudeConfig").value("ui/theme", core.THEME_SYSTEM)
return value if value in core.THEME_CHOICES else core.THEME_SYSTEM
def theme_stylesheet_for(app: QApplication, setting: str | None = None) -> str:
"""Resolve setting + OS appearance into a palette, apply it, return the QSS."""
if setting is None:
setting = stored_theme_setting()
theme = core.resolve_theme(setting, system_is_dark(app))
return apply_palette(core.palette_for(theme))
def main():
if sys.platform == "win32":
# Without an explicit AppUserModelID, Windows taskbar groups the app
@@ -2667,7 +2765,7 @@ def main():
icon = _app_icon()
if not icon.isNull():
app.setWindowIcon(icon)
app.setStyleSheet(STYLESHEET)
app.setStyleSheet(theme_stylesheet_for(app))
win = MainWindow()
win.show()
sys.exit(app.exec())