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
This commit is contained in:
@@ -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.")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user