Define Settings ownership boundaries

This commit is contained in:
Gabriel Brown
2026-08-18 13:27:52 -04:00
parent 9fc1fdbbbb
commit d87f4b6d6a
5 changed files with 179 additions and 15 deletions
+8 -9
View File
@@ -22,16 +22,15 @@ hl.config({
border_size = prefs.get("borderSize", 2), border_size = prefs.get("borderSize", 2),
col = { col = {
-- The prism: blue leads, orchid follows, on a diagonal so the pair -- The focused accent role: blue leads, orchid follows, on a
-- is visible on both a tall and a wide window. Same two colours as -- diagonal so the pair is visible on both a tall and a wide
-- the shell's hairline (quickshell/widgets/PrismEdge.qml) and the -- window. ColorScheme never writes this role; a future accent
-- tmux theme this palette came from. -- picker can own it without fighting light/dark mode.
active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 }, active_border = { colors = { "rgba(82aaffee)", "rgba(b172b0ee)" }, angle = 115 },
-- Unfocused windows get no colour at all. The gradient only means -- The neutral inactive role follows the colour scheme because a
-- something if exactly one window on screen is wearing it. -- dark neutral disappears against a light desktop.
-- Follows the colour scheme: a dark neutral is invisible against a -- services/ColorScheme.qml applies the same values live; this is
-- light desktop. services/ColorScheme.qml applies changes live; -- the value a fresh session starts from.
-- this is the value a fresh session starts from.
inactive_border = prefs.get("colorScheme", "dark") == "light" inactive_border = prefs.get("colorScheme", "dark") == "light"
and "rgba(a8aecb99)" or "rgba(3b426199)", and "rgba(a8aecb99)" or "rgba(3b426199)",
}, },
@@ -503,7 +503,7 @@ Singleton {
{ {
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1, key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
unit: "s", unit: "s",
group: "input", group: "pointer",
label: "Hide pointer after", label: "Hide pointer after",
detail: "Seconds of stillness before the pointer fades out; 0 never hides it", 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 // Reported as a float even though it is only ever set to whole
@@ -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 `hypr/looks.lua` so the Hyprland config still stands alone with no settings
file. 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 ## The rows
| Component | For | | Component | For |
@@ -24,6 +24,11 @@ Singleton {
readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps" readonly property string appThemePath: Quickshell.shellDir + "/scripts/panama-theme-apps"
readonly property bool dark: DesktopPreferences.get("colorScheme") !== "light" 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: "" property string lastError: ""
// Applied one command at a time: Process runs a single command, and several // 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] ["gsettings", "set", "org.gnome.desktop.interface", "gtk-theme", gtkTheme]
]; ];
// Unfocused window borders. The focused border is the prism gradient and // Unfocused window borders need scheme-relative contrast. The focused
// is already scheme-independent; the inactive one is a flat neutral that // Prism border is deliberately owned by the accent/theme layer.
// would be invisible against the opposite background.
const inactive = root.dark ? "rgba(3b426199)" : "rgba(a8aecb99)";
commands.push(["hyprctl", "eval", 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 // Applications that predate org.freedesktop.appearance and carry their
// own palettes -- terminals, chiefly. Everything that reads the portal // own palettes -- terminals, chiefly. Everything that reads the portal
+132
View File
@@ -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"(?<!^)(?=[A-Z])", "-", stem).lower()
rows: dict[str, list[str]] = defaultdict(list)
row_pattern = re.compile(
r"(?:ToggleRow|SliderRow|ChoiceRow|TextEntryRow|TimeOfDayRow)\s*\{(?P<body>.*?)\}",
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<body>.*?)\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"(?<![A-Za-z_])active_border\b", without_comments):
raise SystemExit("ColorScheme writes the focused border")
if 'inactive_border = "${root.inactiveBorder}"' not in scheme:
raise SystemExit("ColorScheme does not apply its effective inactive role")
PY
printf 'settings ownership contract: PASS\n'