Merge feature/23 into integration/spec16 (ticket #23: brand accent hook)

This commit is contained in:
soubarnak
2026-11-06 00:05:37 +05:30
3 changed files with 206 additions and 1 deletions
+2 -1
View File
@@ -419,9 +419,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.")
+74
View File
@@ -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."""
+130
View File
@@ -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"]