Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions prefs.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"always_listen": false,
"speak_volume": 0.6,
"ack_volume": 0.55,
"theme": "glass",
"window": { "w": 520, "h": 480 }
}
48 changes: 48 additions & 0 deletions scripts/test_theme_prefs.py
Original file line number Diff line number Diff line change
@@ -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())
31 changes: 27 additions & 4 deletions src/ask_question_mcp/assets/dialog/dialog.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
12 changes: 9 additions & 3 deletions src/ask_question_mcp/assets/dialog/dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 30 additions & 0 deletions src/ask_question_mcp/assets/dialog/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
10 changes: 6 additions & 4 deletions src/ask_question_mcp/linux_webview_ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions src/ask_question_mcp/prefs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand Down Expand Up @@ -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")
Expand Down
12 changes: 7 additions & 5 deletions src/ask_question_mcp/win_webview_ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions src/ask_question_mcp/zenity_ask.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down