From 203b51ab7814f6e230bf73a71587f2a03b0d41ef Mon Sep 17 00:00:00 2001 From: soubarnak Date: Fri, 6 Nov 2026 00:05:29 +0530 Subject: [PATCH] feat(ui): add brand accent hook to GTK colour scheme BRAND_ACCENT single source with shade()-derived states; accent CSS covers suggested-action buttons and the listening chip, installed at USER priority from main(). Refs #23 --- src/vocalinux/main.py | 3 +- src/vocalinux/utils/gtk_color_scheme.py | 74 ++++++++++++++ tests/test_gtk_color_scheme.py | 130 ++++++++++++++++++++++++ 3 files changed, 206 insertions(+), 1 deletion(-) diff --git a/src/vocalinux/main.py b/src/vocalinux/main.py index f7b6040..152103b 100644 --- a/src/vocalinux/main.py +++ b/src/vocalinux/main.py @@ -418,9 +418,10 @@ def main(): if not check_display_available(): sys.exit(1) - from .utils.gtk_color_scheme import apply_os_color_scheme + from .utils.gtk_color_scheme import apply_brand_accent, apply_os_color_scheme apply_os_color_scheme() + apply_brand_accent() if not check_appindicator_support(): logger.warning("No StatusNotifierWatcher found on D-Bus session bus.") diff --git a/src/vocalinux/utils/gtk_color_scheme.py b/src/vocalinux/utils/gtk_color_scheme.py index 2e361ab..92334c0 100644 --- a/src/vocalinux/utils/gtk_color_scheme.py +++ b/src/vocalinux/utils/gtk_color_scheme.py @@ -11,6 +11,12 @@ Portal ``Read`` is tried first. If it is unavailable or reports no preference, ``gsettings`` is used as a fallback. An unknown ``gsettings`` value or read failure leaves GTK defaults unchanged. ``GTK_THEME`` already set (AppImage or user override) skips the helper entirely. + +The Carbon-mapped house-teal accent hook lives here too: ``BRAND_ACCENT`` is +the single source of truth (a later palette change is a one-line edit) and +``apply_brand_accent()`` paints exactly recording state plus the primary +action with it. Every other surface keeps OS theme variables, and overlay +signal colours plus destructive reds are untouched. """ import logging @@ -32,6 +38,74 @@ _COLOR_SCHEME_KEY = "color-scheme" _PORTAL_TIMEOUT_MS = 1000 _GSETTINGS_TIMEOUT_S = 1 +#: House teal from the Carbon-mapped palette (ticket #23). The single source +#: of truth for the GTK accent hook: recording state and the primary action. +#: White text on this opaque teal passes WCAG AA on light and dark themes. +BRAND_ACCENT = "#0f6b57" + +#: Text colour used on top of the accent. Stays white across themes because +#: the accent chip itself is opaque. +_BRAND_ACCENT_ON = "#ffffff" + + +def brand_accent_css() -> str: + """Return the accent-hook CSS, derived from ``BRAND_ACCENT`` only. + + Covers exactly ``button.suggested-action`` (the primary action) and + ``.recognition-listening`` (the recording-state readout, restyled as an + opaque teal chip so contrast holds on light and dark theme backgrounds). + Hover/active states derive via ``shade()`` so no second literal creeps + in; no ``@theme_`` variable is referenced, so switching the system theme + needs no app restart. + """ + return f"""\ +/* Voculinux house-teal accent hook (#23): recording state + primary action. + All other surfaces keep OS theme variables; overlay signal colours and + the reserved reds are untouched. */ +button.suggested-action {{ + background-color: {BRAND_ACCENT}; + color: {_BRAND_ACCENT_ON}; + border-color: shade({BRAND_ACCENT}, 0.75); +}} +button.suggested-action:hover {{ + background-color: shade({BRAND_ACCENT}, 0.85); + color: {_BRAND_ACCENT_ON}; +}} +button.suggested-action:active {{ + background-color: shade({BRAND_ACCENT}, 0.75); + color: {_BRAND_ACCENT_ON}; +}} +.recognition-listening {{ + background-color: {BRAND_ACCENT}; + color: {_BRAND_ACCENT_ON}; + border-radius: 6px; + padding: 2px 8px; +}} +""" + + +def apply_brand_accent() -> None: + """Install the accent hook above per-dialog CSS without touching it. + + Uses ``STYLE_PROVIDER_PRIORITY_USER`` so the hook wins over the + ``APPLICATION``-priority providers each dialog installs, while every + other rule in those stylesheets keeps following theme variables. + Fails soft: without a display the app keeps theme defaults. + """ + try: + from gi.repository import Gdk, Gtk + + css_provider = Gtk.CssProvider() + css_provider.load_from_data(brand_accent_css().encode()) + Gtk.StyleContext.add_provider_for_screen( + Gdk.Screen.get_default(), + css_provider, + Gtk.STYLE_PROVIDER_PRIORITY_USER, + ) + logger.debug("Applied brand accent %s", BRAND_ACCENT) + except Exception as exc: + logger.debug("Could not apply brand accent: %s", exc) + def read_os_color_scheme() -> Optional[ColorScheme]: """Return ``prefer-dark``, ``prefer-light``, or ``None`` if unknown.""" diff --git a/tests/test_gtk_color_scheme.py b/tests/test_gtk_color_scheme.py index 70a81b1..9cb72d7 100644 --- a/tests/test_gtk_color_scheme.py +++ b/tests/test_gtk_color_scheme.py @@ -1,9 +1,17 @@ """OS color-scheme mapping into gtk-application-prefer-dark-theme (#816).""" +import ast +import re +from pathlib import Path from unittest.mock import MagicMock, patch import pytest +from vocalinux.ui.dictation_overlay import ( + _COLORS, + MODE_LISTENING, + MODE_PROCESSING, +) from vocalinux.utils import gtk_color_scheme as gcs @@ -134,3 +142,125 @@ def test_apply_fail_soft_on_gtk_error(monkeypatch, patched_gi): patched_gi["gtk"].Settings.get_default.side_effect = RuntimeError("no display") gcs.apply_os_color_scheme() + + +# Ticket #23: GTK accent hook in the Carbon-mapped palette. +# +# Seams under test: the single accent constant plus its CSS builder in +# ``vocalinux.utils.gtk_color_scheme`` (public interface: constant value, +# CSS text, provider installation), the overlay signal colours (must not +# move), and the ``main()`` startup wiring (must apply the hook next to the +# OS scheme sync). What these catch: a palette edit in more than one place, +# an accent that follows the theme instead of the house teal, a hook that +# never runs, or an accidental overlay/destructive recolour. What they miss: +# pixel-level rendering on a real desktop (covered by manual acceptance). + + +def _relative_luminance(rgb: tuple) -> float: + """WCAG relative luminance for an (r, g, b) triple in 0..255 (test-local).""" + + def channel(value: int) -> float: + linear = value / 255.0 + if linear <= 0.03928: + return linear / 12.92 + return ((linear + 0.055) / 1.055) ** 2.4 + + red, green, blue = rgb + return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue) + + +def _contrast_ratio(first: tuple, second: tuple) -> float: + """WCAG contrast ratio between two (r, g, b) triples in 0..255.""" + lighter = max(_relative_luminance(first), _relative_luminance(second)) + darker = min(_relative_luminance(first), _relative_luminance(second)) + return (lighter + 0.05) / (darker + 0.05) + + +def test_brand_accent_is_house_teal(): + """The one accent constant is the house teal from the Carbon mapping.""" + assert gcs.BRAND_ACCENT == "#0f6b57" + + +def test_accent_css_covers_primary_action_and_recording_state(): + """Primary action and recording state take the teal; nothing else does.""" + css = gcs.brand_accent_css() + assert "button.suggested-action" in css + assert ".recognition-listening" in css + assert gcs.BRAND_ACCENT in css + for line in css.splitlines(): + if gcs.BRAND_ACCENT in line or "shade(" in line: + assert "@theme_" not in line + + +def test_accent_css_single_source_of_truth(): + """Every literal colour in the hook is the accent or white (one-line edit).""" + css = gcs.brand_accent_css() + literals = {found.lower() for found in re.findall(r"#[0-9a-fA-F]{6}", css)} + assert literals <= {gcs.BRAND_ACCENT.lower(), "#ffffff"} + assert css.count(gcs.BRAND_ACCENT) >= 2 + + +def test_white_on_accent_meets_contrast_in_both_themes(): + """White text on the opaque teal chip passes WCAG AA, light or dark.""" + accent = tuple(int(gcs.BRAND_ACCENT[i : i + 2], 16) for i in (1, 3, 5)) + assert _contrast_ratio((255, 255, 255), accent) >= 4.5 + + +def test_apply_brand_accent_installs_user_priority_provider(patched_gi): + """The hook wins over per-dialog providers without touching their CSS.""" + gdk = MagicMock() + with patch("gi.repository.Gdk", gdk): + gcs.apply_brand_accent() + + gtk = patched_gi["gtk"] + provider = gtk.CssProvider.return_value + provider.load_from_data.assert_called_once() + loaded = provider.load_from_data.call_args[0][0] + assert gcs.BRAND_ACCENT.encode() in loaded + screen = gdk.Screen.get_default.return_value + gtk.StyleContext.add_provider_for_screen.assert_called_once_with( + screen, provider, gtk.STYLE_PROVIDER_PRIORITY_USER + ) + + +def test_apply_brand_accent_fail_soft(patched_gi): + """No display or broken GTK leaves the app running on theme defaults.""" + patched_gi["gtk"].CssProvider.side_effect = RuntimeError("no display") + + gcs.apply_brand_accent() + + +def test_overlay_signal_colours_unchanged(): + """Listening/processing overlay cues keep their previous values.""" + assert _COLORS == { + MODE_LISTENING: (0.18, 0.76, 0.45), + MODE_PROCESSING: (0.95, 0.65, 0.15), + } + css = gcs.brand_accent_css() + assert "#2ec273" not in css + assert "#f2a626" not in css + + +def test_accent_css_leaves_destructive_colours_alone(): + """Red stays reserved for recording/destructive: the hook adds no red.""" + css = gcs.brand_accent_css().lower() + assert "destructive" not in css + assert "error" not in css + assert "#c01c28" not in css + + +def test_main_applies_brand_accent_with_os_scheme_sync(): + """Startup applies the accent hook next to the OS colour-scheme sync.""" + source = Path(__file__).resolve().parents[1].joinpath("src/vocalinux/main.py").read_text() + tree = ast.parse(source) + lines = {} + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if isinstance(node.func, ast.Attribute): + lines.setdefault(node.func.attr, node.lineno) + elif isinstance(node.func, ast.Name): + lines.setdefault(node.func.id, node.lineno) + assert "apply_os_color_scheme" in lines + assert "apply_brand_accent" in lines + assert lines["apply_brand_accent"] > lines["apply_os_color_scheme"]