From d87f4b6d6a34b0593705dff2b75e57107b56330a Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 13:27:52 -0400 Subject: [PATCH] Define Settings ownership boundaries --- config/dot/hypr/looks.lua | 17 ++- .../quickshell/config/PreferenceSchema.qml | 2 +- .../dot/quickshell/modules/settings/README.md | 30 ++++ .../dot/quickshell/services/ColorScheme.qml | 13 +- .../quickshell/settings-ownership-contract.sh | 132 ++++++++++++++++++ 5 files changed, 179 insertions(+), 15 deletions(-) create mode 100755 tests/quickshell/settings-ownership-contract.sh diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index e5a8556..53b563b 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -22,16 +22,15 @@ hl.config({ border_size = prefs.get("borderSize", 2), col = { - -- The prism: blue leads, orchid follows, on a diagonal so the pair - -- is visible on both a tall and a wide window. Same two colours as - -- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the - -- tmux theme this palette came from. + -- The focused accent role: blue leads, orchid follows, on a + -- diagonal so the pair is visible on both a tall and a wide + -- window. ColorScheme never writes this role; a future accent + -- picker can own it without fighting light/dark mode. active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, - -- Unfocused windows get no colour at all. The gradient only means - -- something if exactly one window on screen is wearing it. - -- Follows the colour scheme: a dark neutral is invisible against a - -- light desktop. services/ColorScheme.qml applies changes live; - -- this is the value a fresh session starts from. + -- The neutral inactive role follows the colour scheme because a + -- dark neutral disappears against a light desktop. + -- services/ColorScheme.qml applies the same values live; this is + -- the value a fresh session starts from. inactive_border = prefs.get("colorScheme", "dark") == "light" and "rgba(a8aecb99)" or "rgba(3b426199)", }, diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index a06e6b6..8861a9d 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -503,7 +503,7 @@ Singleton { { key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1, unit: "s", - group: "input", + group: "pointer", label: "Hide pointer after", detail: "Seconds of stillness before the pointer fades out; 0 never hides it", // Reported as a float even though it is only ever set to whole diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index e037ad1..b16936e 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -59,6 +59,36 @@ If it is compositor-backed, add the matching `prefs.get("blurSize", 8)` in `hypr/looks.lua` so the Hyprland config still stands alone with no settings file. +## Setting ownership + +Every preference has **one primary page**, derived from its schema `group` and +the route in `services/SettingsSearch.qml`. Search results always open that +owner. A control may appear on a second page only when the same adaptation is +part of another established mental model; otherwise use a labelled handoff to +the owner instead of duplicating it. + +### Intentional mirrors + +| Setting | Primary page | Mirror | Why the mirror earns its place | +|---|---|---|---| +| `animationsEnabled` | Appearance | Accessibility | Reduced motion belongs both to visual polish and motion accessibility. | +| `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behaviour but affects motor and visual access. | +| `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. | +| `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. | +| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. | +| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. | + +Mirrors must remain the same schema-backed control, never a second preference +or a copied default. Additions to this table require a concrete discoverability +reason and an update to `tests/quickshell/settings-ownership-contract.sh`. + +Window border colour follows the same ownership rule. The inactive border is a +**scheme-relative role** owned by `ColorScheme.qml`: it changes only to retain +neutral contrast in light and dark modes. The focused Prism border is the +accent role owned by the visual theme (and, eventually, an accent picker). +`ColorScheme.qml` must never write the focused border, so changing schemes +cannot erase a user-selected accent. + ## The rows | Component | For | diff --git a/config/dot/quickshell/services/ColorScheme.qml b/config/dot/quickshell/services/ColorScheme.qml index 2de1c7b..7a5bc28 100644 --- a/config/dot/quickshell/services/ColorScheme.qml +++ b/config/dot/quickshell/services/ColorScheme.qml @@ -24,6 +24,11 @@ Singleton { readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps" readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light" + // A neutral contrast role, not an accent. The focused Prism border belongs + // to the visual theme and must remain untouched when this role changes. + readonly property string inactiveBorderDark: "rgba(3b426199)" + readonly property string inactiveBorderLight: "rgba(a8aecb99)" + readonly property string inactiveBorder: root.dark ? root.inactiveBorderDark : root.inactiveBorderLight property string lastError: "" // Applied one command at a time: Process runs a single command, and several @@ -99,12 +104,10 @@ Singleton { ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme] ]; - // Unfocused window borders. The focused border is the prism gradient and - // is already scheme-independent; the inactive one is a flat neutral that - // would be invisible against the opposite background. - const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)"; + // Unfocused window borders need scheme-relative contrast. The focused + // Prism border is deliberately owned by the accent/theme layer. commands.push(["hyprctl", "eval", - `hl.config({ general = { col = { inactive_border = "${inactive}" } } })`]); + `hl.config({ general = { col = { inactive_border = "${root.inactiveBorder}" } } })`]); // Applications that predate org.freedesktop.appearance and carry their // own palettes -- terminals, chiefly. Everything that reads the portal diff --git a/tests/quickshell/settings-ownership-contract.sh b/tests/quickshell/settings-ownership-contract.sh new file mode 100755 index 0000000..68fc365 --- /dev/null +++ b/tests/quickshell/settings-ownership-contract.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash + +# A setting has one schema-routed owner. A second page may mirror it only when +# this contract names the exact owner and mirror set. The same ownership rule +# keeps colour scheme propagation away from the focused Prism border. + +set -euo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +pages_dir="$repo_dir/config/dot/quickshell/modules/settings" +schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml" +search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml" +scheme="$repo_dir/config/dot/quickshell/services/ColorScheme.qml" +looks="$repo_dir/config/dot/hypr/looks.lua" +readme="$pages_dir/README.md" + +fail() { + printf 'settings ownership contract: %s\n' "$1" >&2 + exit 1 +} + +python3 - "$pages_dir" "$schema" "$search" <<'PY' \ + || fail 'page ownership or intentional mirrors drifted' +from __future__ import annotations + +import re +import sys +from collections import defaultdict +from pathlib import Path + +pages_dir = Path(sys.argv[1]) +schema_text = Path(sys.argv[2]).read_text(encoding="utf-8") +search_text = Path(sys.argv[3]).read_text(encoding="utf-8") + +expected = { + "animationsEnabled": {"owner": "appearance", "mirrors": {"accessibility"}}, + "cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}}, + "cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}}, + "inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}}, + "lockMinutes": {"owner": "power", "mirrors": {"privacy"}}, + "lockOnSleep": {"owner": "power", "mirrors": {"privacy"}}, +} + + +def strip_comments(text: str) -> str: + return re.sub(r"//.*", "", text) + + +def page_name(path: Path) -> str: + stem = path.stem.removesuffix("Page") + return re.sub(r"(?.*?)\}", + re.S, +) +for page_path in pages_dir.glob("*Page.qml"): + text = strip_comments(page_path.read_text(encoding="utf-8")) + for match in row_pattern.finditer(text): + setting = re.search(r'setting\s*:\s*"([^"]+)"', match.group("body")) + if setting: + rows[setting.group(1)].append(page_name(page_path)) + +duplicates = {key: set(pages) for key, pages in rows.items() if len(pages) > 1} +if set(duplicates) != set(expected): + raise SystemExit( + f"duplicate keys are {sorted(duplicates)}, expected {sorted(expected)}" + ) + +group_pages = dict(re.findall(r'"([^"]+)"\s*:\s*"([^"]+)"', search_text)) +for key, policy in expected.items(): + wanted_pages = {policy["owner"], *policy["mirrors"]} + if duplicates[key] != wanted_pages: + raise SystemExit(f"{key} appears on {sorted(duplicates[key])}, expected {sorted(wanted_pages)}") + + block = re.search( + r'\{\s*\n\s*key:\s*"' + re.escape(key) + r'"(?P.*?)\n\s*\}', + schema_text, + re.S, + ) + if not block: + raise SystemExit(f"schema entry missing for {key}") + group = re.search(r'group:\s*"([^"]+)"', block.group("body")) + if not group: + raise SystemExit(f"schema group missing for {key}") + routed = group_pages.get(group.group(1)) + if routed != policy["owner"]: + raise SystemExit( + f"{key} routes to {routed!r}, expected primary owner {policy['owner']!r}" + ) +PY + +for needle in \ + '## Setting ownership' \ + 'one primary page' \ + 'Intentional mirrors' \ + '`animationsEnabled`' \ + '`cursorInactiveTimeout`' \ + '`cursorSize`' \ + '`inactiveOpacity`' \ + '`lockMinutes`' \ + '`lockOnSleep`' \ + 'scheme-relative role'; do + rg -Fq "$needle" "$readme" || fail "README is missing $needle" +done + +python3 - "$scheme" "$looks" <<'PY' \ + || fail 'scheme-relative border ownership drifted' +import re +import sys + +scheme = open(sys.argv[1], encoding="utf-8").read() +looks = open(sys.argv[2], encoding="utf-8").read() + +dark = re.search(r'property string inactiveBorderDark:\s*"([^"]+)"', scheme) +light = re.search(r'property string inactiveBorderLight:\s*"([^"]+)"', scheme) +effective = re.search(r'property string inactiveBorder:\s*root\.dark\s*\?\s*root\.inactiveBorderDark\s*:\s*root\.inactiveBorderLight', scheme) +if not dark or not light or not effective: + raise SystemExit("ColorScheme does not expose the two inactive-border roles") +if dark.group(1) not in looks or light.group(1) not in looks: + raise SystemExit("Hyprland startup values disagree with the live scheme roles") + +without_comments = re.sub(r"//.*", "", scheme) +if re.search(r"(?