Commit 6fb0f98d by PLN (Algolia)

feat(tray): Bridge-styled menu, de-dashed copy, keyboard mnemonics

The double click was the appindicator extension's, not ours: it reserves
double-click for SNI Activate (instant menu) and delays single-click by
400 ms, so a single click feels dead. Nothing to fix tray-side; documented
in the changelog.

- BRIDGE_QSS restyles the Qt-drawn popup on dark desktops: tonal surfaces,
  hairline borders, hover pill, magenta on the engaged radio; circles for
  radio groups, squares for boolean toggles. Contrast measured: body 14.7:1,
  disabled 6.4:1, radio ring 3.3:1 (bumped from 2.3:1 in review).
- stat rows and the thermal tooltip read in tabular digits.
- every user-visible string loses its em dash (colons, parens, middots);
  same pass on gearbox.py labels (shared with the web Bridge) and the
  launcher blurbs; unknown gearbox placeholder is now n/a, not a bare dash.
- mnemonics on the action rows (&Open Bridge, &GIG UP, &Restart SuperDirt,
  R&ig, &Start at login, Cut G&PU, &Quit); verified over DBusMenu that Qt
  exports them as underscores, so the shell-drawn twin gets them too.
  Literal ampersand does not survive the export, so the copy avoids it.
parent 4ea24cb0
......@@ -4,6 +4,35 @@ Sprint entries, newest first. Player-facing: what changed about *playing*, not
about the code. Task IDs reference the L'Armada board; `n/a` where the work was
unplanned (which, on a gig night, is most of it).
## Sprint 11 — 2026-09-17 · the tray learns which click is which
### Fixed
- **Why the tray wanted a double click.** Not perf-tray's code: Qt advertises
the SNI `Activate` method, the appindicator extension reserves double-click
for it (instant menu) and delays single-click by 400 ms before opening the
shell-drawn one, so a single click feels dead and fingers learn to click
twice. Nothing to fix in the tray; the delay is the extension's, and there is
no setting for it. Bonus discovery: the icon has been serving TWO menus with
two skins all along (instant Qt-drawn on double click, GNOME-styled on
single/right click).
### Changed
- **The Qt-drawn menu got the Ship's Bridge look** (`armada/DESIGN.md`): tonal
dark surfaces, hairline borders, hover pill, brand magenta on the engaged
radio, tabular digits on the stat rows and the thermal tooltip. Only applies
when the desktop asks for dark; the shell-drawn menus keep the desktop skin,
because a StatusNotifierItem lets us style only what Qt renders itself. All
contrast pairs measured (body 14.7:1, disabled 6.4:1, radio ring 3.3:1 after
a bump from 2.3:1 caught in review). Rendered offscreen before shipping.
- **Menu copy de-dashed and mnemonics added.** Every user-visible string now
uses colons, parentheses and middots instead of em dashes (statuses in
parentheses, armed warnings after a colon, descriptions after one). Action
rows carry keyboard mnemonics (&Open Bridge, &GIG UP, &Restart SuperDirt,
R&ig, &Start at login, Cut G&PU, &Quit): with the menu open, the letter
fires the row. The gearbox client's labels (`tools/bridge/gearbox.py`,
shared with the web Bridge) got the same punctuation pass, and its unknown
placeholder is now "n/a" instead of a bare dash.
## Sprint 10 — 2026-09-11 (midday) · the second audio server, again
A launch started by hand ("I opened Ardour and Pulsar and ran sclang myself")
......
"""
gearbox — Python client for the thermal gearbox (SRE/thermal, GEARBOX.md).
gearbox: Python client for the thermal gearbox (SRE/thermal, GEARBOX.md).
ONE source of the gearbox vocabulary for every face: the Qt tray (perf-tray.py,
the face PLN actually uses mid-set), the web Bridge, and anything else that grows
later. The engine itself is shell and lives in the SRE repo — this module never
later. The engine itself is shell and lives in the SRE repo; this module never
duplicates its POLICY, it only names the gears and shells out.
The model, in one paragraph (the long version is SRE/thermal/GEARBOX.md):
Two ORTHOGONAL axes, not one list of modes.
GEAR — how hard the machine may work: off / standard / aggressive
TRIM — how the heat gets paid for: quiet / auto / cool
GEAR (how hard the machine may work): off / standard / aggressive
TRIM (how the heat gets paid for): quiet / auto / cool
Trim applies in EVERY gear. Quiet and cool are in tension: you cannot be fast
AND quiet AND cool, and the whole point of splitting the axes is that the UI
stops pretending you can.
Reading is unprivileged (a plain file); only SWITCHING needs root, via
`sudo -n` against the root-owned, sudoers-whitelisted helper — the same contract
`sudo -n` against the root-owned, sudoers-whitelisted helper, the same contract
perf.py uses for perf-audio. `-n` matters: the tray has no tty, so an
interactive sudo would hang a menu click forever instead of failing loudly.
"""
......@@ -27,14 +27,14 @@ from pathlib import Path
# Root-owned helper installed by SRE/thermal/install.sh. Every one of the argv
# forms built below is an exact-args NOPASSWD rule in /etc/sudoers.d/thermal-mode
# — that exactness is the security model, so DO NOT interpolate anything into
# that exactness is the security model, so DO NOT interpolate anything into
# these commands beyond a key validated against the tables below.
HELPER = os.environ.get("THERMAL_MODE_HELPER", "/usr/local/sbin/thermal-mode-apply")
CONF = Path(os.environ.get("THERMAL_MODE_CONF", "/etc/thermal-mode.conf"))
# gear key -> (label, blurb). Order = the lever's travel, idle → hard.
GEARS = {
"off": ("Off", "stop steering — ppd gets the knobs back"),
"off": ("Off", "stop steering; ppd gets the knobs back"),
"standard": ("Standard", "the everyday livecoding regime"),
"aggressive": ("Aggressive", "full watts the source can give, turbo, RT audio"),
}
......@@ -76,7 +76,7 @@ def read_conf(conf=None):
def state(conf=None):
"""Where the lever is, read unprivileged.
`gear`/`trim` are None when never set — that is NOT the same as "off" (a
`gear`/`trim` are None when never set; that is NOT the same as "off" (a
deliberate off) and the distinction is the whole reason `off` had to become
an explicit gear rather than the absence of one.
"""
......@@ -95,7 +95,7 @@ def state(conf=None):
def gear_argv(key):
"""argv to engage a gear. Raises ValueError on anything not in the table —
"""argv to engage a gear. Raises ValueError on anything not in the table:
the sudoers rules are exact-args, so an unvalidated key can only produce a
confusing sudo refusal instead of an honest error."""
if key not in GEARS:
......@@ -114,7 +114,7 @@ def trim_argv(key):
def apply(kind, key, timeout=25):
"""Blocking switch, for the web face and tests. Returns (ok, message).
The Qt tray does NOT use this — it drives the same argv through QProcess so a
The Qt tray does NOT use this: it drives the same argv through QProcess so a
menu click cannot freeze the tray while RAPL zones and fan profiles settle
(measured: an aggressive→standard switch takes about a second, and the RT
ladder walk can add more).
......@@ -134,12 +134,12 @@ def apply(kind, key, timeout=25):
def label(kind, key):
"""Human label for a key, or the key itself when it is unknown/None. Faces
show what the file SAYS even if this module has never heard of it — a
show what the file SAYS even if this module has never heard of it; a
gearbox newer than its UI must not render a blank."""
table = GEARS if kind == "gear" else TRIMS
if key in table:
return table[key][0]
return key or "—"
return key or "n/a"
def summary(conf=None):
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment