#!/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 # says who writes each half of the window border: ColorScheme.qml owns the # neutral inactive role AND the accent-derived focused role, restating both # together on every scheme or accent change. 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" catalog="$repo_dir/config/dot/quickshell/config/themes.json" 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 and lockOnSleep used to be mirrored onto Privacy, which was # the one mirror in this table that nobody had asked for: Privacy carried a # whole second Screen-lock card, so the same preference had two sliders and # the page you were looking at might not be the one you last set. The card # is gone and Privacy points at Power & Lock instead, so the mirror went # with it. Power owns them outright now. } 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`' \ 'scheme-relative role' \ 'mode, scale, rotation, arrangement, and primary role'; do rg -Fq "$needle" "$readme" || fail "README is missing $needle" done python3 - "$scheme" "$looks" "$catalog" <<'PY' \ || fail 'window border ownership drifted' import json import re import sys scheme = open(sys.argv[1], encoding="utf-8").read() looks = open(sys.argv[2], encoding="utf-8").read() themes = {t["id"]: t for t in json.load(open(sys.argv[3], encoding="utf-8"))["themes"]} # Hyprland's own startup value has to stand alone with no settings file, so it # carries a literal pair -- but that pair is the two DEFAULT themes' gutters, # the same role ColorScheme restates the moment the shell is up. A drift here # is a visible flash of the wrong border on every login. for theme_id in ("moon", "day"): expected = "rgba(" + themes[theme_id]["palette"]["gutter"].lstrip("#") + "99)" if expected not in looks: raise SystemExit( f"Hyprland's startup inactive_border does not carry {theme_id}'s gutter ({expected})") # The inactive border is a neutral contrast role, and it is now drawn from the # ACTIVE THEME's gutter rather than from two Tokyo Night literals. Those # literals were right for two of the ten shipped themes and for no custom one, # so a hardcoded pair here is the regression worth catching. inactive = re.search( r'property string inactiveBorder:\s*root\.hyprColor\(Theme\.gutter,', scheme) if not inactive: raise SystemExit("the inactive border is no longer the active theme's neutral role") if re.search(r'property string inactiveBorder(Dark|Light):\s*"#', scheme): raise SystemExit("ColorScheme has regrown a hardcoded inactive-border literal") if re.search(r'rgba\([0-9a-f]{8}\)', scheme): raise SystemExit("ColorScheme has regrown a literal Hyprland border colour") without_comments = re.sub(r"//.*", "", scheme) # The focused border is the accent role, and ColorScheme.qml owns it too: # each named accent carries a separate pair per scheme, so a scheme change # must restate the focused border, not just the neutral one, or a chosen # accent goes stale the moment light/dark flips. Both roles are also restated # when the THEME changes, since both now follow the resolved palette. start = re.search(r'property string accentBorderStart:\s*root\.hyprColor\(Theme\.accent,', scheme) end = re.search(r'property string accentBorderEnd:\s*root\.hyprColor\(Theme\.accentSecondary,', scheme) if not start or not end: raise SystemExit("ColorScheme does not derive the focused border from the chosen accent") if 'themeChanged' not in without_comments: raise SystemExit("ColorScheme does not restate its borders when the theme changes") if 'schemeChanged || themeChanged' not in without_comments: raise SystemExit("the inactive border is not restated on a theme change") if 'schemeChanged || accentChanged || themeChanged' not in without_comments: raise SystemExit("the focused border is not restated on a theme change") # Written as a Lua TABLE, not a string: the string form of a Hyprland gradient # carries only one stop, so writing it that way is accepted and silently # keeps whatever the previous accent left behind. Built through the same # serializeValue() SystemSettings.qml uses for every other gradient, rather # than a second hand-rolled (and unescaped) copy of that table syntax here. if 'SystemSettings.serializeValue({' not in without_comments: raise SystemExit("ColorScheme does not build the focused border through the shared gradient serializer") if 'colors: [root.accentBorderStart, root.accentBorderEnd]' not in without_comments: raise SystemExit("ColorScheme does not derive the focused-border gradient from the chosen accent") if 'active_border = ${activeBorder}' not in without_comments: raise SystemExit("ColorScheme does not write the focused border as the serialized gradient table") 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'