From 4d36641f8a6708c4b4daa784fb9d254e1c93d459 Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Mon, 10 Aug 2026 10:09:37 +0100 Subject: [PATCH] feat(mcq): add Nebula light theme alongside glass dark Daylight tokens (cool paper + steel accent), prefs/env theme resolution, and tests. Glass remains the default until the human opts into light. Co-authored-by: Cursor --- README.md | 3 +- prefs.example.json | 1 + scripts/test_theme_prefs.py | 48 +++++++++++++++++++ src/ask_question_mcp/assets/dialog/dialog.css | 31 ++++++++++-- src/ask_question_mcp/assets/dialog/dialog.js | 12 +++-- src/ask_question_mcp/assets/dialog/tokens.css | 30 ++++++++++++ src/ask_question_mcp/linux_webview_ask.py | 10 ++-- src/ask_question_mcp/prefs.py | 45 +++++++++++++++++ src/ask_question_mcp/win_webview_ask.py | 12 +++-- src/ask_question_mcp/zenity_ask.py | 10 ++-- 10 files changed, 182 insertions(+), 20 deletions(-) create mode 100644 scripts/test_theme_prefs.py diff --git a/README.md b/README.md index 44c299a..5689ec4 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ Full env / prefs: [SETUP.md](SETUP.md). Never commit tokens. returned as MCP image blocks (max 4, no lasting files) - **Idle timeout hold:** default `timeout_sec=0` (waits). If a timeout is set, typing / paste / select cancels auto-close until OK/Cancel -- Linux aesthetic: **Nebula** dark glass (inspired by the Windows WebView dialog) +- Linux aesthetic: **Nebula** glass (dark default) + **light** daylight companion + (`ASK_QUESTION_THEME` / prefs `theme`: `glass` | `light` | `ink` | `signal` | `hybrid`) - Remembers last dialog size (`prefs.window`; position on Windows; size-only on typical Wayland) - Windows: scrollable option list + same lead/detail Confirm layout diff --git a/prefs.example.json b/prefs.example.json index 0ace4e8..723d26b 100644 --- a/prefs.example.json +++ b/prefs.example.json @@ -5,5 +5,6 @@ "always_listen": false, "speak_volume": 0.6, "ack_volume": 0.55, + "theme": "glass", "window": { "w": 520, "h": 480 } } diff --git a/scripts/test_theme_prefs.py b/scripts/test_theme_prefs.py new file mode 100644 index 0000000..9d9122c --- /dev/null +++ b/scripts/test_theme_prefs.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +"""Theme normalize + prefs resolution known-goods.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from ask_question_mcp import prefs # noqa: E402 + + +def main() -> int: + assert prefs.normalize_theme("DAY") == "light" + assert prefs.normalize_theme("dark") == "glass" + assert prefs.normalize_theme("nope") is None + + tmp = Path(os.environ.get("TMPDIR") or "/tmp") / "ask-question-mcp-theme-test" + tmp.mkdir(parents=True, exist_ok=True) + prefs_path = tmp / "prefs.json" + prefs._PREFS_PATH = prefs_path # type: ignore[attr-defined] + + os.environ.pop("ASK_QUESTION_THEME", None) + prefs_path.write_text('{"theme": "light"}\n', encoding="utf-8") + assert prefs.get_theme() == "light" + + os.environ["ASK_QUESTION_THEME"] = "glass" + assert prefs.get_theme() == "glass", "env must win over prefs" + + tokens = ( + ROOT / "src" / "ask_question_mcp" / "assets" / "dialog" / "tokens.css" + ).read_text(encoding="utf-8") + assert '[data-theme="light"]' in tokens + + js = ( + ROOT / "src" / "ask_question_mcp" / "assets" / "dialog" / "dialog.js" + ).read_text(encoding="utf-8") + assert '"light"' in js + + print("PASS test_theme_prefs") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/ask_question_mcp/assets/dialog/dialog.css b/src/ask_question_mcp/assets/dialog/dialog.css index 8cd0b35..65289eb 100644 --- a/src/ask_question_mcp/assets/dialog/dialog.css +++ b/src/ask_question_mcp/assets/dialog/dialog.css @@ -86,6 +86,22 @@ body { filter: blur(80px); } +[data-theme="light"] .aura::before, +[data-theme="light"] .aura::after { + opacity: 0.22; + filter: blur(72px); +} + +[data-theme="light"] .aura-grain { + opacity: 0.03; + mix-blend-mode: multiply; +} + +html:has([data-theme="light"]), +body:has([data-theme="light"]) { + color-scheme: light; +} + @keyframes aura-drift { from { transform: translate(0, 0) scale(1); @@ -111,12 +127,19 @@ body { animation-duration: var(--star-speed-near, 90s); } -.stars.far { - animation-duration: var(--star-speed-far, 150s); - animation-delay: calc(var(--star-speed-far, 150s) * -0.32); - opacity: 0.78; +[data-theme="light"] .stars.far { + opacity: 0.35; +} + +[data-theme="light"] .star-dot { + background: rgb(40 60 80 / 0.35); } +[data-theme="light"] .star-dot.is-bright { + background: rgb(15 110 140 / 0.55); +} + + @keyframes wind-drift { 0% { transform: translate3d(0, 0, 0); diff --git a/src/ask_question_mcp/assets/dialog/dialog.js b/src/ask_question_mcp/assets/dialog/dialog.js index f2eac74..ea2bf0b 100644 --- a/src/ask_question_mcp/assets/dialog/dialog.js +++ b/src/ask_question_mcp/assets/dialog/dialog.js @@ -711,9 +711,15 @@ const theme = String(payload.theme || "glass").toLowerCase(); const app = $("#app"); if (app) { - app.dataset.theme = ["glass", "ink", "signal", "hybrid"].includes(theme) - ? theme - : "glass"; + const allowed = ["glass", "ink", "signal", "hybrid", "light"]; + app.dataset.theme = allowed.includes(theme) ? theme : "glass"; + const scheme = app.dataset.theme === "light" ? "light" : "dark"; + try { + document.documentElement.style.colorScheme = scheme; + document.body.style.colorScheme = scheme; + } catch (_) { + /* ignore */ + } } const pre = payload.preselect || payload.recommended_ids || []; state.selected = new Set( diff --git a/src/ask_question_mcp/assets/dialog/tokens.css b/src/ask_question_mcp/assets/dialog/tokens.css index f05f421..6f983d1 100644 --- a/src/ask_question_mcp/assets/dialog/tokens.css +++ b/src/ask_question_mcp/assets/dialog/tokens.css @@ -100,3 +100,33 @@ --r-tile: 18px; --r-tile-inner: 15px; } + +/* Daylight — light companion to glass (cool paper, steel accent — not purple-on-white). */ +[data-theme="light"] { + --ground: #eef1f5; + --panel: #f7f8fb; + --card-core: #ffffff; + --raised: #e4e9f0; + --accent: #0f6e8c; + --accent-text: #0a5570; + --ember: #c43d55; + --text: #15202b; + --text-secondary: #4a5a6a; + --text-tertiary: #6b7c8c; + --text-eyebrow: #5a6b7c; + --ground-deep: #e2e7ee; + --ground-rgb: 238 241 245; + --accent-rgb: 15 110 140; + --ember-rgb: 196 61 85; + --card-core-rgb: 255 255 255; + --panel-rgb: 247 248 251; + --raised-rgb: 228 233 240; + --hairline-rgb: 20 30 40; + --orb-a: 90 160 190; + --orb-b: 180 140 100; + --danger-bg: rgb(196 61 85 / 0.10); + --danger-border: rgb(196 61 85 / 0.32); + --danger-text: #8f2940; + --r-tile: 16px; + --r-tile-inner: 13px; +} diff --git a/src/ask_question_mcp/linux_webview_ask.py b/src/ask_question_mcp/linux_webview_ask.py index 99c04df..6515a9e 100644 --- a/src/ask_question_mcp/linux_webview_ask.py +++ b/src/ask_question_mcp/linux_webview_ask.py @@ -927,10 +927,12 @@ def main() -> int: ui_payload["arm_ms"] = int(_danger_arm.danger_arm_ms(dangerous=True)) else: ui_payload["arm_ms"] = 250 - theme = str( - payload.get("theme") or os.environ.get("ASK_QUESTION_THEME") or "glass" - ) - ui_payload["theme"] = theme.strip().lower() or "glass" + theme_raw = payload.get("theme") or os.environ.get("ASK_QUESTION_THEME") + if _prefs is not None and hasattr(_prefs, "normalize_theme"): + canon = _prefs.normalize_theme(theme_raw) if theme_raw else None + ui_payload["theme"] = canon or _prefs.get_theme() + else: + ui_payload["theme"] = str(theme_raw or "glass").strip().lower() or "glass" if ui_payload["dangerous"] and _danger_arm is not None: title = _danger_arm.prefix_danger_mark(title) if payload.get("entry_seed") is not None: diff --git a/src/ask_question_mcp/prefs.py b/src/ask_question_mcp/prefs.py index 5122065..ca3cfe2 100644 --- a/src/ask_question_mcp/prefs.py +++ b/src/ask_question_mcp/prefs.py @@ -18,6 +18,8 @@ - ``ASK_QUESTION_ACK=0|1`` — spoken ack after OK (``ack_enabled``; default off) - ``ASK_QUESTION_ALWAYS_LISTEN=0|1`` — auto mic after speak (default off) - ``ASK_QUESTION_SPEAK_VOLUME`` / ``ASK_QUESTION_ACK_VOLUME`` (linear 0.01–1.0) +- ``ASK_QUESTION_THEME=glass|light|ink|signal|hybrid`` — Nebula dialog look + (``glass`` dark default; ``light`` = daylight companion) """ from __future__ import annotations @@ -40,10 +42,27 @@ "always_listen": False, "speak_volume": 0.60, "ack_volume": 0.55, + # Nebula WebView look: glass (dark) | light | ink | signal | hybrid + "theme": "glass", # Last dialog size/position (x/y may be ignored on Wayland). "window": {"w": 520, "h": 480}, } +_THEMES = frozenset({"glass", "light", "ink", "signal", "hybrid"}) +_THEME_ALIASES: dict[str, str] = { + "glass": "glass", + "dark": "glass", + "nebula": "glass", + "night": "glass", + "light": "light", + "day": "light", + "daylight": "light", + "lumen": "light", + "ink": "ink", + "signal": "signal", + "hybrid": "hybrid", +} + def defaults() -> dict[str, Any]: """Shipped defaults (copy) — used when no prefs.json / env override.""" @@ -180,6 +199,32 @@ def set_speak_volume(volume: float) -> None: save_prefs({"speak_volume": _clamp_vol(volume, float(_DEFAULTS["speak_volume"]))}) +def normalize_theme(raw: Any) -> str | None: + """Return canonical theme id or None if unset/unknown.""" + if raw is None: + return None + key = str(raw).strip().lower().replace("-", "_").replace(" ", "_") + if not key: + return None + return _THEME_ALIASES.get(key) + + +def get_theme() -> str: + """Nebula dialog theme: env ``ASK_QUESTION_THEME`` → prefs → glass.""" + env = normalize_theme(os.environ.get("ASK_QUESTION_THEME")) + if env: + return env + pref = normalize_theme(load_prefs().get("theme")) + if pref: + return pref + return str(_DEFAULTS["theme"]) + + +def set_theme(theme: str) -> None: + canon = normalize_theme(theme) or "glass" + save_prefs({"theme": canon}) + + def get_window_geometry() -> dict[str, int]: """Last dialog size/position. Keys may include ``w``, ``h``, ``x``, ``y``.""" raw = load_prefs().get("window") diff --git a/src/ask_question_mcp/win_webview_ask.py b/src/ask_question_mcp/win_webview_ask.py index c4e6840..d203a3e 100644 --- a/src/ask_question_mcp/win_webview_ask.py +++ b/src/ask_question_mcp/win_webview_ask.py @@ -357,11 +357,13 @@ def main() -> int: if _danger_arm is not None else (4000 if ui_payload["dangerous"] else 1000) ) - # Aesthetic theme: glass | ink | signal (CSS data-theme). - theme = str( - payload.get("theme") or os.environ.get("ASK_QUESTION_THEME") or "glass" - ) - ui_payload["theme"] = theme.strip().lower() or "glass" + # Aesthetic theme: glass | light | ink | signal | hybrid (CSS data-theme). + theme_raw = payload.get("theme") or os.environ.get("ASK_QUESTION_THEME") + if _prefs is not None and hasattr(_prefs, "normalize_theme"): + canon = _prefs.normalize_theme(theme_raw) if theme_raw else None + ui_payload["theme"] = canon or _prefs.get_theme() + else: + ui_payload["theme"] = str(theme_raw or "glass").strip().lower() or "glass" if ui_payload["dangerous"] and _danger_arm is not None: title = _danger_arm.prefix_danger_mark(title) diff --git a/src/ask_question_mcp/zenity_ask.py b/src/ask_question_mcp/zenity_ask.py index 0f63c13..1c83b8e 100644 --- a/src/ask_question_mcp/zenity_ask.py +++ b/src/ask_question_mcp/zenity_ask.py @@ -9,7 +9,7 @@ Windows: prefer frameless Nebula WebView2 (``win_webview_ask.py``, theme ``glass`` by default); then Edge ``--app`` (``win_edge_ask.py``); then tkinter. Override with ``ASK_QUESTION_WIN_UI=pywebview|edge|tk|auto`` and -``ASK_QUESTION_THEME=glass|ink|signal|hybrid``. +``ASK_QUESTION_THEME=glass|light|ink|signal|hybrid``. Recommended options are listed first and pre-selected. Dangerous decisions get danger chrome. Window title includes the raising agent/lane. On Linux, @@ -782,6 +782,9 @@ def _ask_list( "capability_notes": list(capability_notes or []), # Preview is Linux Gtk-only for now; paths ignored on Windows. "images": image_paths, + "theme": __import__( + "ask_question_mcp.prefs", fromlist=["get_theme"] + ).get_theme(), } try: raw, rc, err = _run_win_dialog( @@ -908,8 +911,9 @@ def _ask_list( "audio_mode": audio_mode, "capability_notes": list(capability_notes or []), "images": image_paths, - "theme": (os.environ.get("ASK_QUESTION_THEME") or "glass").strip().lower() - or "glass", + "theme": __import__( + "ask_question_mcp.prefs", fromlist=["get_theme"] + ).get_theme(), } env = {**os.environ, "DISPLAY": display} if use_nebula: