feat: light theme + system-following, dark preserved exactly (#75) #80

Merged
the_og merged 2 commits from feat/75-theming into main 2026-07-20 12:51:01 -04:00
Owner

Refs #75

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

Note: I originally filed #75 with the premise backwards ("BCC renders white on a dark desktop"). Corrected on the issue — I'd inferred "no theme code" from a grep without reading the constants.

What's here

  • Palette value type in bcc_core, with DARK_PALETTE (byte-identical to v1.3.0's colours) and a new LIGHT_PALETTE.
  • resolve_theme(setting, system_is_dark) — pure, testable without a Qt app.
  • View ▸ Theme → Match system / Light / Dark, persisted in QSettings under ui/theme, defaulting to system.
  • STYLESHEETbuild_stylesheet(palette).

Three decisions worth reviewing

The light palette is not the dark one lightened. #4ade80 sits near 1.7:1 against white — illegible. The semantic colours are darkened to clear WCAG AA, and test_palette_meets_contrast_on_panel enforces ≥4.5:1 for every text colour against its surface in both palettes, so nobody harmonises them back toward the dark hues later. Measured: light good 5.02, warn 4.92, accent 5.18, muted 6.11.

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

The ~20 inline setStyleSheet(f"color: {MUTED}") call sites are untouched. apply_palette rebinds the module-level colour names, and an f-string resolves its names when it runs — so every call site picks up the new colour on its next render, with no signature churn. The tradeoff: already-rendered widgets keep their old inline style until re-rendered, so _set_theme reapplies the global QSS and re-renders the inline-styled widgets rather than leaving dark-on-light text behind.

Tests

389 passed, 1 skipped (+15). Ruff clean.

Covers resolve_theme across all three settings × both appearances; junk/hand-edited settings falling back to system rather than a fixed theme; dark palette asserted unchanged from the shipped look; contrast on every slot in both palettes; and the two source-level guards above.

test_contrast_ratio_endpoints pins the WCAG maths against known values (black/white = 21.0, identical = 1.0) so the contrast assertions can't pass by way of a broken formula.

What I could not verify

No visual confirmation. I tried rendering MainWindow offscreen under both palettes, but PySide6 needs libEGL.so.1 and I don't have root in this sandbox to install it. So this is verified at the level of "the QSS is built from the palette, contains no stray literals, and every colour meets contrast" — not "I looked at the light theme and it appears correct."

Worth a human eyeballing the light theme before merge, particularly: the disabled/parked servers table against the main table, the diagnostics pane, and selected rows (on_accent on accent).

Note

One test I wrote (every_palette_slot_is_used_by_the_stylesheet) failed on first run and was wrong rather than the code: good and remote legitimately never appear in the QSS — they feed the inline status dots through STATUS_COLORS/HEALTH_COLORS. Rewritten as test_every_palette_slot_is_consumed, with a docstring stating what it does and doesn't catch.

Refs #75 BCC has always been dark-only — `BG = "#1b1d23"`, bound at import time, with no light option and no awareness of the desktop appearance. On a light desktop it matches nothing else on screen and there's no way to change it. > Note: I originally filed #75 with the premise backwards ("BCC renders white on a dark desktop"). Corrected on the issue — I'd inferred "no theme code" from a grep without reading the constants. ## What's here - `Palette` value type in `bcc_core`, with `DARK_PALETTE` (byte-identical to v1.3.0's colours) and a new `LIGHT_PALETTE`. - `resolve_theme(setting, system_is_dark)` — pure, testable without a Qt app. - **View ▸ Theme** → Match system / Light / Dark, persisted in `QSettings` under `ui/theme`, defaulting to system. - `STYLESHEET` → `build_stylesheet(palette)`. ## Three decisions worth reviewing **The light palette is not the dark one lightened.** `#4ade80` sits near 1.7:1 against white — illegible. The semantic colours are darkened to clear WCAG AA, and `test_palette_meets_contrast_on_panel` enforces ≥4.5:1 for every text colour against its surface in *both* palettes, so nobody harmonises them back toward the dark hues later. Measured: light `good` 5.02, `warn` 4.92, `accent` 5.18, `muted` 6.11. **Three near-black literals were baked into the QSS** — `#1a1205` (on-accent text), `#202229` (disabled table), `#16181d` (diagnostics pane). Harmless with one theme, invisible breakage with two. Each has a palette slot now, and `test_stylesheet_builder_has_no_hardcoded_colours` asserts the builder contains no hex literals at all. **The ~20 inline `setStyleSheet(f"color: {MUTED}")` call sites are untouched.** `apply_palette` rebinds the module-level colour names, and an f-string resolves its names when it *runs* — so every call site picks up the new colour on its next render, with no signature churn. The tradeoff: already-rendered widgets keep their old inline style until re-rendered, so `_set_theme` reapplies the global QSS *and* re-renders the inline-styled widgets rather than leaving dark-on-light text behind. ## Tests 389 passed, 1 skipped (+15). Ruff clean. Covers `resolve_theme` across all three settings × both appearances; junk/hand-edited settings falling back to system rather than a fixed theme; dark palette asserted unchanged from the shipped look; contrast on every slot in both palettes; and the two source-level guards above. `test_contrast_ratio_endpoints` pins the WCAG maths against known values (black/white = 21.0, identical = 1.0) so the contrast assertions can't pass by way of a broken formula. ## What I could not verify **No visual confirmation.** I tried rendering `MainWindow` offscreen under both palettes, but PySide6 needs `libEGL.so.1` and I don't have root in this sandbox to install it. So this is verified at the level of "the QSS is built from the palette, contains no stray literals, and every colour meets contrast" — *not* "I looked at the light theme and it appears correct." Worth a human eyeballing the light theme before merge, particularly: the disabled/parked servers table against the main table, the diagnostics pane, and selected rows (`on_accent` on `accent`). ## Note One test I wrote (`every_palette_slot_is_used_by_the_stylesheet`) failed on first run and was wrong rather than the code: `good` and `remote` legitimately never appear in the QSS — they feed the inline status dots through `STATUS_COLORS`/`HEALTH_COLORS`. Rewritten as `test_every_palette_slot_is_consumed`, with a docstring stating what it does and doesn't catch.
the_og added 1 commit 2026-07-20 12:23:00 -04:00
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
febd617c56
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
the_og added 1 commit 2026-07-20 12:50:11 -04:00
Merge branch 'main' into feat/75-theming
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
fa82d30087
Union conflict at the end of tests/test_core.py -- both branches appended a
test block. Kept both, main's #72/#73 block first. Verified: 265 test
functions = 238 baseline + 15 (#77) + 12 (theming), no duplicates.
the_og merged commit 9a0433225e into main 2026-07-20 12:51:01 -04:00
Sign in to join this conversation.