Files
Panama/tests/quickshell/accessibility-contract
T

890 lines
40 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# Accessibility: every switch on the page does something, and the things that
# make it usable without a pointer are not decorations.
#
# The old page was six cards of controls that mostly worked and one card that
# apologised. What it did not have was any of the machinery an accessibility
# page exists for: the magnifier had no shortcut, so zooming meant opening
# Settings with a screen you could not read; "Reduce motion" stilled the
# compositor while the shell's own bar, dock and panels went on sliding; there
# was no visual alternative to the notification bell at all; and every shared
# settings row -- the rows that make up all fourteen categories -- was invisible
# to a screen reader and unreachable by Tab.
#
# Five properties are pinned here, in descending order of what it would cost to
# get them wrong:
#
# 1. THE ZOOM SHORTCUT GOES THROUGH THE STORED PREFERENCE. The obvious
# implementation is `hyprctl keyword cursor:zoom_factor 1.25`, and on this
# Lua-configured Hyprland that write is REFUSED while exiting 0 (see
# SystemSettings.qml's own note). The zoom would appear to work in testing
# and do nothing on the machine, and even where it did work the stored
# preference would be a lie -- the slider on this page would read 1.00 for
# a screen that is magnified fourfold. The bind therefore commits through
# SystemSettings.commitPreference like every other preference.
# 2. The visual bell flashes ONCE, and it flashes for people who cannot hear.
# A looping flash on a screen is not an alert, it is a hazard -- it is the
# exact stimulus photosensitive-epilepsy guidance exists about -- so no
# infinite animation and no repeating timer may sit behind it. And it is
# deliberately NOT gated on the event-sounds switch: a visual alert is for
# somebody who turned sounds off, or cannot hear them, and gating it on
# sound would make it fire only for people who did not need it.
# 3. Orca is controlled as a process, never as a gsettings key. GNOME's
# `screen-reader-enabled` is applied by gnome-settings-daemon, which does
# not run in this session: writing it stores a preference that starts
# nothing, which is the single most costly kind of lie an accessibility
# page can tell.
# 4. The nine shared row primitives carry a screen-reader name, a role, and a
# Tab stop. These rows ARE the settings application; getting this right
# once is what makes every page reachable, and losing it in one file
# silently removes a whole class of control from the keyboard.
# 5. Reduce motion is true of the shell itself, not only of the compositor.
#
# Entirely static. Nothing here starts a shell, applies a zoom, starts or stops
# Orca, or writes a gsettings key -- which is also the point: the failures this
# guards against are ones a live run would report as working.
set -uo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
shell_dir="$repo_dir/config/dot/quickshell"
settings="$shell_dir/modules/settings"
page="$settings/AccessibilityPage.qml"
qmldir="$settings/qmldir"
schema="$shell_dir/config/PreferenceSchema.qml"
theme="$shell_dir/config/Theme.qml"
shell_qml="$shell_dir/shell.qml"
service="$shell_dir/services/Accessibility.qml"
notifs="$shell_dir/services/Notifs.qml"
visual_bell="$shell_dir/modules/notifications/VisualBell.qml"
osd_model="$shell_dir/modules/osd/OsdModel.js"
search="$shell_dir/services/SettingsSearch.qml"
keybinds="$repo_dir/config/dot/hypr/keybinds.lua"
findings=()
note() { findings+=("$1"); }
for file in "$page" "$schema" "$theme" "$shell_qml" "$service" "$notifs" \
"$osd_model" "$search" "$keybinds" "$qmldir"; do
[[ -r "$file" ]] || { printf 'accessibility contract: missing %s\n' "${file#"$repo_dir/"}" >&2; exit 1; }
done
# The page and the components only it uses, as one surface. A card lifted into
# a component of its own is a normal thing to do while building this, and every
# assertion below about what the page shows would quietly stop meaning anything
# if it only ever read AccessibilityPage.qml. Shared rows are excluded by the
# same test that finds these: a component another settings page also
# instantiates is not part of this page's own structure.
mapfile -t page_files < <(python3 - "$page" "$settings" <<'PY'
import os
import re
import sys
page, settings = sys.argv[1], sys.argv[2]
source = open(page, encoding="utf-8").read()
others = [os.path.join(settings, name) for name in os.listdir(settings)
if name.endswith(".qml") and os.path.join(settings, name) != page]
other_text = "\n".join(open(path, encoding="utf-8").read() for path in others)
files = [page]
for name in sorted(set(re.findall(r"\b([A-Z][A-Za-z0-9]+) \{", source))):
candidate = os.path.join(settings, name + ".qml")
if not os.path.exists(candidate):
continue
if re.search(r"\b" + name + r" \{", other_text):
continue
files.append(candidate)
print("\n".join(files))
PY
)
page_has() { grep -Fq "$@" "${page_files[@]}"; }
page_matches() { grep -Eq "$@" "${page_files[@]}"; }
# Comment-stripped page text, for the assertions that must not be satisfied by
# prose. This file and the page both discuss the things they deliberately do
# not do, and a contract that read a comment as an implementation would pass on
# a page that only talked about working.
page_code="$(sed -E 's://.*::' "${page_files[@]}")"
# ── 1. The zoom shortcut ─────────────────────────────────────────────────────
# The IPC target the binds call, and the verb they call on it.
zoom_handler="$(python3 - "$shell_qml" <<'PY'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
match = re.search(r'IpcHandler \{\s*\n\s+target: "accessibility"(?P<body>.*?)\n \}',
source, re.S)
print(match.group("body") if match else "")
PY
)"
if [[ -z "$zoom_handler" ]]; then
note 'shell.qml declares no "accessibility" IPC target, so the zoom keybinds call nothing'
else
grep -qE 'function zoom\(' <<<"$zoom_handler" \
|| note 'the accessibility IPC target has no zoom verb, so `qs ipc call accessibility zoom in` fails'
grep -q 'Accessibility\.' <<<"$zoom_handler" \
|| note 'the accessibility IPC handler does not route to the Accessibility service, so the zoom logic lives in shell.qml where nothing else can reach it'
# THE assertion, from the shell's side. A handler that reaches for hyprctl
# itself has bypassed both the store and the verified write.
grep -qE 'hyprctl' <<<"$(sed -E 's://.*::' <<<"$zoom_handler")" \
&& note 'the accessibility IPC handler runs hyprctl directly -- the zoom must commit through the preference, or the slider on the page will disagree with the screen'
fi
# The service function the handler calls, sliced by brace depth from its
# signature so the assertions below are about the zoom path and not about the
# rest of the file.
step_zoom="$(python3 - "$service" <<'PY'
import re
import sys
lines = open(sys.argv[1], encoding="utf-8").read().splitlines()
def body_of(name):
"""One function's source, sliced by brace depth from its signature."""
for index, line in enumerate(lines):
if not re.search(rf"\bfunction {re.escape(name)}\s*\(", line):
continue
depth = 0
body = []
for current in lines[index:]:
body.append(current)
depth += current.count("{") - current.count("}")
if depth == 0 and len(body) > 1:
break
return "\n".join(body)
return ""
# stepZoom plus everything in this file it hands the work to. Splitting the OSD
# call or the commit into a helper is a normal thing to do, and the assertions
# below are about the zoom PATH rather than about one function's text.
seen = []
queue = ["stepZoom"]
collected = []
while queue:
name = queue.pop(0)
if name in seen:
continue
seen.append(name)
body = body_of(name)
if not body:
continue
collected.append(body)
for call in re.findall(r"\broot\.(\w+)\s*\(", body):
if call not in seen:
queue.append(call)
if collected:
print("\n".join(collected))
PY
)"
if [[ -z "$step_zoom" ]]; then
note 'services/Accessibility.qml has no stepZoom(), so there is nothing behind the zoom IPC'
else
step_code="$(sed -E 's://.*::' <<<"$step_zoom")"
# THE assertion. `hyprctl keyword` is refused outright by a Lua-configured
# Hyprland while exiting 0, and even a working direct write would leave the
# stored magnifierFactor saying the screen is not magnified.
grep -qE '\bhyprctl\b' <<<"$step_code" \
&& note 'the zoom path shells out to hyprctl -- the zoom must go through SystemSettings.commitPreference, which is the only path that both stores the value and verifies the write'
grep -qE 'keyword' <<<"$step_code" \
&& note 'the zoom path names the hyprctl keyword verb, which this codebase does not use: a Lua-configured Hyprland refuses that write and exits 0 anyway'
grep -q 'SystemSettings\.commitPreference' <<<"$step_code" \
|| note 'the zoom path does not commit through SystemSettings.commitPreference, so the magnifier slider and the actual magnification can disagree'
grep -q 'magnifierFactor' <<<"$step_code" \
|| note 'the zoom path does not name the magnifierFactor preference, so whatever it changes is not the setting this page shows'
# The OSD is the whole reason the bind goes through the shell rather than
# being a hyprctl one-liner: somebody who cannot read the screen needs to
# be told what the zoom level now is.
grep -qE 'OsdState\.' <<<"$step_code" \
|| note 'the zoom path posts no OSD, which removes the only reason to route the zoom keys through the shell at all'
# Bounds come from the schema, or match it. A hardcoded clamp that drifts
# from the schema means the keys stop at one number and the slider at
# another.
step_body="$(mktemp /tmp/panama-a11y-zoom.XXXXXX)"
printf '%s\n' "$step_code" >"$step_body"
python3 - "$schema" "$step_body" <<'PY' || note 'the zoom path clamps the zoom to numbers that are not the schema range for magnifierFactor, so the keys and the slider stop at different magnifications'
import re
import sys
schema = open(sys.argv[1], encoding="utf-8").read()
body = open(sys.argv[2], encoding="utf-8").read()
match = re.search(r'\{\s*\n\s+key: "magnifierFactor".*?\n\s{8}\}', schema, re.S)
if not match:
raise SystemExit(1)
entry = match.group(0)
bounds = {name: float(re.search(rf'\b{name}: ([0-9.]+)', entry).group(1))
for name in ("min", "max")}
# Reading the spec is the better answer and passes outright.
if "PreferenceSchema" in body or ".spec(" in body or "root.spec" in body:
raise SystemExit(0)
numbers = {float(value) for value in re.findall(r'\b\d+\.\d+\b|\b\d+\b', body)}
raise SystemExit(0 if bounds["min"] in numbers and bounds["max"] in numbers else 1)
PY
rm -f "$step_body"
fi
# The binds themselves: three of them, each naming the IPC verb, and none of
# them sitting on a chord that is already answering to something else.
#
# The chords are NOT pinned by name. Super+= was already "Reset split" when
# this was designed, so which free chords the zoom keys take is a decision made
# against the file rather than against the mock, and a contract that insisted
# on Super+= would have been wrong on the day it was written.
#
# Matched on the target and verb rather than on how the command string is
# built: `qs("accessibility", "zoom in")` and a literal `qs ipc call
# accessibility zoom in` are the same bind, and which one keybinds.lua uses is
# a style question.
bind_lines="$(grep -E 'bind\(' "$keybinds" | tr -s ' ')"
zoom_binds="$(grep -cE '"accessibility"[^)]*zoom|accessibility zoom' <<<"$bind_lines")"
if (( zoom_binds < 3 )); then
note "keybinds.lua has $zoom_binds zoom bind(s) calling the accessibility IPC, expected three (in, out, reset)"
fi
for verb in in out reset; do
grep -qE "(\"accessibility\"[^)]*zoom ${verb}|accessibility zoom ${verb})\b" <<<"$bind_lines" \
|| note "no keybind calls the accessibility zoom \"$verb\" verb"
done
# A bind sitting on an occupied chord is silently one bind: Hyprland takes the
# last one and the earlier action stops working, which is how a magnifier
# shortcut removes "Reset split" without anybody noticing.
python3 - "$keybinds" <<'PY' || note 'a chord in keybinds.lua is bound twice, so one of the two actions is unreachable'
import re
import sys
seen = {}
duplicates = []
for line in open(sys.argv[1], encoding="utf-8"):
match = re.match(r'\s*bind\("([^"]+)"', line)
if not match:
continue
chord = match.group(1).strip().lower()
if chord in seen:
duplicates.append(chord)
seen[chord] = True
if duplicates:
print("duplicate chords: " + ", ".join(sorted(set(duplicates))), file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# Whatever kind the zoom OSD posts, the OSD has to be able to draw it. An
# unrecognised kind falls through OsdModel.iconFor to the kind's own name,
# which is not an icon and renders as the fallback glyph.
if [[ -n "$step_zoom" ]] && command -v node >/dev/null 2>&1; then
kind="$(grep -oE 'OsdState\.(progress|message)\(\s*"[a-z-]+"' <<<"$step_zoom" \
| head -1 | grep -oE '"[a-z-]+"' | tr -d '"')"
if [[ -n "$kind" ]]; then
node - "$osd_model" "$kind" <<'JS' || note "OsdModel has no icon for the \"$kind\" kind the zoom OSD posts, so the magnifier OSD draws the generic fallback"
const model = require(process.argv[2])
const kind = process.argv[3]
const icon = model.iconFor(kind, 0.5)
process.exit(icon !== kind && /-symbolic$/.test(icon) ? 0 : 1)
JS
fi
fi
# ── 2. The visual bell ───────────────────────────────────────────────────────
if [[ ! -r "$visual_bell" ]]; then
note 'modules/notifications/VisualBell.qml does not exist, so the visual alerts switch has nothing behind it'
else
bell_code="$(sed -E 's://.*::' "$visual_bell")"
# THE assertion. A screen that keeps flashing is not an alert.
grep -qE 'loops:\s*(Animation\.Infinite|-1)' <<<"$bell_code" \
&& note 'the visual bell loops forever -- a repeating full-screen flash is the stimulus photosensitivity guidance exists about, and it must fire once per notification'
python3 - "$visual_bell" <<'PY' || note 'the visual bell is driven by a repeating Timer, so the flash restarts on its own rather than answering one notification'
import re
import sys
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
for match in re.finditer(r"Timer \{", source):
depth = 0
body = []
for char in source[match.start():]:
body.append(char)
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
break
block = "".join(body)
if re.search(r"\brepeat:\s*true", block):
raise SystemExit(1)
raise SystemExit(0)
PY
grep -q 'visualAlerts' <<<"$bell_code" \
|| note 'the visual bell does not read the visualAlerts preference, so the switch on the page does not turn it off'
# It draws, it does not act. A flash overlay that shells out has become
# something other than an overlay.
grep -qE '\bProcess\b' <<<"$bell_code" \
&& note 'VisualBell shells out; an alert overlay draws and nothing else'
# The two halves of this redesign collide here, and the collision is silent.
# Theme's duration tokens now collapse to 0 under Reduce motion, so a flash
# timed with Theme.durFast would last no time at all -- switching on Reduce
# motion would switch OFF Visual alerts, for somebody quite likely to want
# both. The flash is information rather than decoration, so it keeps its own
# timings.
grep -qE 'Theme\.dur' <<<"$bell_code" \
&& note 'the visual bell is timed with a Theme.dur token, which collapses to 0 under Reduce motion -- turning on Reduce motion would silently turn off Visual alerts'
fi
# The eligibility rule, which is the subtle half. The flash follows the bell
# EXCEPT for the event-sounds gate: somebody who turned sounds off is precisely
# who the flash is for.
# Which signal, read from the overlay that listens rather than guessed by name:
# `function onBellEligible` in VisualBell means the signal is `bellEligible`.
# Deriving it from the consumer also pins that the two are really wired, which
# a grep for a name of this contract's choosing would not.
flash_signal=""
if [[ -r "$visual_bell" ]]; then
flash_signal="$(python3 - "$visual_bell" <<'PY'
import re
import sys
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
match = re.search(r"Connections \{[^}]*?target: Notifs(?P<body>.*?)\n \}", source, re.S)
if match:
handler = re.search(r"function on([A-Z]\w*)\s*\(", match.group("body"))
if handler:
name = handler.group(1)
print(name[0].lower() + name[1:])
PY
)"
fi
if [[ -z "$flash_signal" ]]; then
flash_signal="$(grep -oE 'signal +[A-Za-z]*([Ff]lash|[Vv]isualAlert|[Bb]ellEligible)[A-Za-z]*' "$notifs" \
| head -1 | awk '{print $2}' | sed -E 's/\(.*//')"
fi
if [[ -z "$flash_signal" ]]; then
note 'services/Notifs.qml declares no flash signal, so nothing tells the visual bell a notification arrived'
else
python3 - "$notifs" "$flash_signal" <<'PY' || note "the $flash_signal signal is emitted after the SoundFeedback.eventSounds guard, so the visual alert only fires for people whose sounds are already on -- exactly the people who do not need it"
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
source = re.sub(r"//.*", "", source)
lines = source.splitlines()
signal = sys.argv[2]
emits = [index for index, line in enumerate(lines)
if re.search(rf"\b(root\.)?{re.escape(signal)}\s*\(", line)
and not re.match(r"\s*signal\b", line)]
if not emits:
raise SystemExit(1)
for emit in emits:
# Walk up to the enclosing function, then check whether an eventSounds
# early return stands between its first line and the emit.
start = 0
for index in range(emit, -1, -1):
if re.search(r"\bfunction\s+\w+\s*\(", lines[index]):
start = index
break
region = lines[start:emit]
for offset, line in enumerate(region):
if "eventSounds" not in line:
continue
# A guard is an eventSounds test followed by a return within a line
# or two of it.
tail = "\n".join(region[offset:offset + 3])
if "return" in tail:
raise SystemExit(1)
raise SystemExit(0)
PY
# ...and the rest of the bell's eligibility DOES apply, otherwise the flash
# is not "the bell, seen" but a second, louder notifier that ignores every
# per-application rule somebody set.
python3 - "$notifs" "$flash_signal" <<'PY' || note "the $flash_signal signal does not sit on the bell's eligibility path, so the flash ignores the per-application sound rule, low urgency, and suppress-sound"
import re
import sys
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
lines = source.splitlines()
signal = sys.argv[2]
emits = [index for index, line in enumerate(lines)
if re.search(rf"\b(root\.)?{re.escape(signal)}\s*\(", line)
and not re.match(r"\s*signal\b", line)]
if not emits:
raise SystemExit(1)
markers = ("appRule", "suppress-sound", "NotificationUrgency.Low", "bell")
for emit in emits:
start = 0
for index in range(emit, -1, -1):
if re.search(r"\bfunction\s+(\w+)\s*\(", lines[index]):
start = index
break
region = "\n".join(lines[start:emit + 1])
if not any(marker in region for marker in markers):
raise SystemExit(1)
raise SystemExit(0)
PY
# The rule is subtle enough that the next reader will "fix" it unless the
# file says why. Pinned so the explanation cannot be deleted separately
# from the behaviour.
grep -qiE 'eventSounds' "$notifs" \
|| note 'Notifs no longer mentions eventSounds at all, so the deliberate exception has nothing to be an exception to'
python3 - "$notifs" <<'PY' || note 'nothing in Notifs.qml explains why the visual flash is not gated on event sounds -- an undocumented exception to the bell rule is one somebody will helpfully remove'
import re
import sys
comments = "\n".join(re.findall(r"//.*", open(sys.argv[1], encoding="utf-8").read())).lower()
hits = ("flash" in comments or "visual" in comments) and (
"hear" in comments or "eventsounds" in comments or "event sounds" in comments)
raise SystemExit(0 if hits else 1)
PY
fi
# Per-screen, like every other overlay in this shell: a flash on one display of
# three is a notification most of the screen never showed.
grep -q 'VisualBell' "$shell_qml" \
|| note 'shell.qml never instantiates VisualBell, so the overlay exists but is never on screen'
# ── 3. Orca, as a process ────────────────────────────────────────────────────
service_code="$(sed -E 's://.*::' "$service")"
# THE assertion, swept over everything this redesign touches rather than over
# the service alone: gnome-settings-daemon applies screen-reader-enabled, and
# it does not run here.
while IFS= read -r hit; do
note "screen-reader-enabled is written in ${hit%%:*} -- that key is applied by gnome-settings-daemon, which this session does not run, so the write starts nothing"
done < <(grep -rn 'screen-reader-enabled' "$shell_dir" --include='*.qml' --include='*.js' \
| sed -E 's://.*::' | grep 'screen-reader-enabled' || true)
grep -q 'orcaRunning' "$service" \
|| note 'services/Accessibility.qml exposes no orcaRunning, so the page cannot say whether the screen reader is on'
grep -qE '\bpgrep\b' <<<"$service_code" \
|| note 'nothing probes for a running Orca process, so the running state is a guess'
grep -qE 'gsettings[^\n]*(screen-reader|a11y|applications)' <<<"$service_code" \
&& note 'Orca is being controlled through gsettings rather than as a process'
# Poll discipline. A pgrep on a repeating timer is a subprocess every few
# seconds for the whole session, for a page almost nobody has open.
python3 - "$service" <<'PY' || note 'the Orca probe runs on a repeating Timer, so the shell spawns a pgrep forever for a page that is almost never open -- the probe belongs on page open and after each action'
import re
import sys
source = re.sub(r"//.*", "", open(sys.argv[1], encoding="utf-8").read())
for match in re.finditer(r"Timer \{", source):
depth = 0
body = []
for char in source[match.start():]:
body.append(char)
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
break
block = "".join(body)
if "repeat: true" in block and ("orca" in block.lower() or "pgrep" in block):
raise SystemExit(1)
raise SystemExit(0)
PY
# The readiness line the mock shows is a real reading, not a sentence.
page_matches -i 'accessibility bus|a11y bus|at-spi' \
|| note 'the screen reader card does not mention the accessibility bus, which is the one thing that decides whether starting Orca would achieve anything'
# ── 4. The page, per the approved mock ───────────────────────────────────────
# Five cards, and the five subjects people arrive with. The old page sorted by
# mechanism (Pointer, Text, Magnifier, Contrast); this one sorts by which sense
# or limb is being accommodated, which is how somebody looking for help thinks
# about it.
for card in Vision Motion Hearing 'Keyboard' 'Screen reader'; do
grep -Eqi "title: \"[^\"]*${card}" <<<"$page_code" \
|| note "the page has no \"$card\" card; the approved structure is Vision / Motion / Hearing / Keyboard & pointer / Screen reader"
done
for retired in 'title: "Pointer"' 'title: "Text"' 'title: "Magnifier"' \
'title: "Contrast"' 'title: "Keyboard accessibility"'; do
grep -Fq "$retired" <<<"$page_code" \
&& note "the retired card ${retired#title: } is still on the page, so the rebuild left the old mechanism-sorted structure behind it"
done
# Dim amount is meaningless while dim is off, and a live slider that changes
# nothing is worse than a greyed one: it invites somebody to conclude the
# setting is broken.
python3 - "${page_files[@]}" <<'PY' || note 'the dimStrength slider is not gated on dimInactive, so the page offers a live control that does nothing until another switch is on'
import re
import sys
source = "\n".join(re.sub(r"//.*", "", open(path, encoding="utf-8").read())
for path in sys.argv[1:])
for match in re.finditer(r"SliderRow \{", source):
depth = 0
body = []
for char in source[match.start():]:
body.append(char)
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
break
block = "".join(body)
if '"dimStrength"' not in block:
continue
raise SystemExit(0 if re.search(r"enabled:.*dimInactive", block, re.S) else 1)
raise SystemExit(1)
PY
# The dead zero label, swept across every settings page rather than only this
# one. `SliderRow.display()` substitutes zeroLabel at exactly 0, so a row that
# sets one for a setting whose schema minimum is above 0 is copy that can never
# appear -- which is what "Off" was doing under a magnifier whose minimum is 1.
python3 - "$schema" "$settings" <<'PY' || note 'a SliderRow sets a zeroLabel for a setting whose schema minimum is above zero, so the label can never be shown -- SliderRow.display() substitutes it only at exactly 0'
import os
import re
import sys
schema = open(sys.argv[1], encoding="utf-8").read()
minimums = {}
for block in re.findall(r'\{\s*\n\s+key: "\w+".*?\n\s{8}\}', schema, re.S):
key = re.search(r'key: "(\w+)"', block).group(1)
low = re.search(r"\bmin: ([0-9.-]+)", block)
if low:
minimums[key] = float(low.group(1))
bad = []
for name in sorted(os.listdir(sys.argv[2])):
if not name.endswith(".qml"):
continue
source = re.sub(r"//.*", "", open(os.path.join(sys.argv[2], name), encoding="utf-8").read())
for match in re.finditer(r"SliderRow \{[^}]*\}", source):
block = match.group(0)
if "zeroLabel" not in block:
continue
key = re.search(r'setting: "(\w+)"', block)
if key and minimums.get(key.group(1), 0.0) > 0:
bad.append(f"{name}: {key.group(1)}")
if bad:
print("dead zero labels: " + ", ".join(bad), file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# ...and the honesty that replaced it says what "off" is, in the units the
# readout uses.
python3 - "$schema" "${page_files[@]}" <<'PY' || note 'nothing on the page or in the schema says that a magnification of one is off, so the magnifier has no off position anybody can find'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
text += "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[2:])
raise SystemExit(0 if re.search(r"1(\.0+)?\s*×[^\"]*\bis off\b", text) else 1)
PY
# The chord display. The page tells somebody which keys zoom; if it says so in
# a string of its own, that string is a second spelling of keybinds.lua and
# will outlive the bind. Either it reads the Keybinds service, or the chords it
# names are chords that file really binds.
python3 - "$keybinds" "${page_files[@]}" <<'PY' || note 'the page prints zoom chords that keybinds.lua does not bind, so the shortcut it advertises is not the shortcut that works'
import re
import sys
binds = open(sys.argv[1], encoding="utf-8").read().lower()
source = "\n".join(re.sub(r"//.*", "", open(path, encoding="utf-8").read())
for path in sys.argv[2:])
if "Keybinds." in source:
raise SystemExit(0)
chords = re.findall(r'chord: "([^"]+)"', source)
for chord in chords:
keys = [part.strip().lower() for part in chord.split("+") if part.strip()]
if not keys or "super" not in keys:
continue
# The chord as keybinds.lua would spell it: "SUPER + equal".
if not all(re.search(rf'bind\("[^"]*\b{re.escape(key)}\b', binds) for key in keys):
raise SystemExit(1)
raise SystemExit(0)
PY
# The honesty box. It exists because searching for "sticky keys" and finding
# nothing reads as the desktop having no opinion; the box is the opinion, and
# it has to carry the evidence rather than a shrug.
honesty="$(python3 - "${page_files[@]}" <<'PY'
import re
import sys
source = "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[1:])
for match in re.finditer(r"[A-Z][A-Za-z0-9]* \{", source):
depth = 0
body = []
for char in source[match.start():]:
body.append(char)
if char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
break
block = "".join(body)
if re.search(r"sticky", block, re.I) and len(block) < 4000:
print(block)
break
PY
)"
if [[ -z "$honesty" ]]; then
note 'the page says nothing about sticky, slow or bounce keys, so someone who needs them is told nothing at all'
else
grep -qi 'hyprland' <<<"$honesty" \
|| note 'the sticky-keys honesty box does not name Hyprland, so it reads as a Wayland-wide excuse rather than as a fact about this compositor'
grep -qiE 'asked|probed|checked|no such option|does not implement' <<<"$honesty" \
|| note 'the honesty box states the absence without saying it was probed rather than assumed, which is the difference between a finding and a shrug'
grep -qiE 'daemon|gnome-settings-daemon|gsd|not run' <<<"$honesty" \
|| note 'the honesty box does not explain why the GNOME switches for these are inert here, which is the half people go looking for next'
# Not styled as a failure. This is a statement of what the compositor does
# not implement yet, not a fault the reader has to act on, and dressing it
# in the danger token would make an accessibility page open with an alarm.
grep -qE 'Theme\.(danger|warn)\b' <<<"$honesty" \
&& note 'the honesty box is painted in the danger or warning token, which turns a plain statement of scope into an alarm on the page least able to afford one'
for word in Warning Error Unsupported unfortunately sorry Broken; do
grep -qw "$word" <<<"$honesty" \
&& note "the honesty box uses urgency language (\"$word\"); it is describing what the compositor does not implement yet, not reporting a fault"
done
fi
# Mono audio: deferred out loud, with somewhere to go meanwhile.
python3 - "${page_files[@]}" <<'PY' || note 'the mono-audio row is missing, or it is missing the NOT YET marking that keeps it from reading as a control'
import re
import sys
source = "\n".join(open(path, encoding="utf-8").read() for path in sys.argv[1:])
if not re.search(r"mono", source, re.I):
raise SystemExit(1)
raise SystemExit(0 if re.search(r"NOT YET|Not yet|not offered yet", source) else 1)
PY
page_matches -i 'balance|sound page|Sound settings' \
|| note 'the mono-audio row defers without saying where the nearest real thing is; Balance is on the Sound page'
# The Keyboard jump, through the router rather than by assignment.
page_has 'ShellState.openSettings("shortcuts")' \
|| note 'the Keyboard & pointer card does not offer the jump to Keyboard settings through openSettings(), which is the only way the routing contract can see where it lands'
page_has 'ShellState.settingsPage = ' \
&& note 'the page sets the settings page by hand, bypassing SettingsRoutes.resolve()'
# The page is presentation. Starting Orca is the service's job, so one copy of
# it can be shared and the running state can be read back.
for file in "${page_files[@]}"; do
grep -qE '\bProcess\b' "$file" \
&& note "${file#"$settings/"} shells out; the Accessibility page's subprocesses belong in services/Accessibility.qml"
done
# ── 5. The nine primitives ───────────────────────────────────────────────────
#
# These rows are the settings application. A page is a list of them, so losing
# the name or the Tab stop in one file removes a whole class of control from
# the keyboard across all fourteen categories at once -- and it does it
# silently, since nothing about the row looks or behaves differently.
#
# Resolved through the QML inheritance chain: ToggleRow extends SettingRow, and
# a name inherited from the base is a name the row really has. What may NOT be
# inherited is the Tab stop on a control the file declares itself -- SettingRow
# is a Tab stop only when it is activatable, which a ToggleRow is not, so a
# file that draws its own switch or button has to make that switch reachable.
python3 - "$settings" <<'PY' || note 'one of the nine shared row primitives is missing a screen-reader name, a role, or a Tab stop (details above)'
import os
import re
import sys
settings = sys.argv[1]
primitives = ["SliderRow", "ToggleRow", "SwitchRow", "ActionRow", "ChoiceRow",
"SegmentRow", "SettingRow", "OptionPickerRow", "PickerRow"]
sources = {}
bases = {}
for name in primitives:
path = os.path.join(settings, name + ".qml")
if not os.path.exists(path):
print(f"{name}.qml does not exist", file=sys.stderr)
raise SystemExit(1)
text = re.sub(r"//.*", "", open(path, encoding="utf-8").read())
sources[name] = text
root = re.search(r"^([A-Z][A-Za-z0-9]*) \{", text, re.M)
bases[name] = root.group(1) if root else ""
def chain(name):
seen = []
while name in sources and name not in seen:
seen.append(name)
name = bases[name]
return seen
# Controls a file declares itself. A row that draws one of these owns an
# interactive element, and the Tab stop has to be on it here.
CONTROLS = ("MouseArea", "TapHandler", "SettingsToggle", "SettingsButton",
"ValueSlider")
problems = []
for name in primitives:
inherited = "\n".join(sources[link] for link in chain(name))
own = sources[name]
for needle, what in (("Accessible.name", "a screen-reader name"),
("Accessible.role", "a screen-reader role")):
if needle not in inherited:
problems.append(f"{name}.qml has no {what} ({needle}), so a screen reader reads it as an unnamed element")
if "activeFocusOnTab" not in inherited:
problems.append(f"{name}.qml has no activeFocusOnTab anywhere in its chain, so Tab walks past it")
elif any(control in own for control in CONTROLS) and "activeFocusOnTab" not in own:
problems.append(f"{name}.qml draws its own control but declares no activeFocusOnTab of its own; the base is a Tab stop only when activatable, which this row is not")
for problem in problems:
print(problem, file=sys.stderr)
raise SystemExit(1 if problems else 0)
PY
# Keys reach the controls, not only the rows: Space and Enter activate, and the
# two rows that hold a range answer the arrow keys. A focus ring nobody can act
# from is a Tab stop that wastes a keystroke.
for file in SettingRow ToggleRow SwitchRow ActionRow; do
grep -qE 'Keys\.on(Space|Return|Enter)Pressed|Accessible\.onPressAction|Accessible\.onToggleAction' \
"$settings/$file.qml" \
|| note "$file.qml can be focused but not activated from the keyboard, so Tab lands on a control that does nothing"
done
for file in SliderRow SegmentRow; do
grep -qE 'Keys\.on(Left|Right)Pressed|Accessible\.on(Increase|Decrease)Action' \
"$settings/$file.qml" \
|| note "$file.qml does not answer the arrow keys, so a focused slider or segment cannot be changed without a pointer"
done
# A focus ring. Focus that cannot be seen is focus a sighted keyboard user
# loses track of on the second Tab.
python3 - "$settings" <<'PY' || note 'no row primitive draws a visible focus indicator, so keyboard focus is invisible'
import os
import re
import sys
settings = sys.argv[1]
names = ["SliderRow", "ToggleRow", "SwitchRow", "ActionRow", "ChoiceRow",
"SegmentRow", "SettingRow", "OptionPickerRow", "PickerRow"]
for name in names:
text = re.sub(r"//.*", "", open(os.path.join(settings, name + ".qml"), encoding="utf-8").read())
if re.search(r"activeFocus\b", text) and re.search(r"border\.|Rectangle", text):
raise SystemExit(0)
raise SystemExit(1)
PY
# And the visual-at-rest promise: these rows are used by every page, so the
# accessibility work was allowed to add nothing that shows when nothing is
# focused. A border painted unconditionally would restyle all fourteen
# categories.
python3 - "$settings" <<'PY' || note 'a row primitive paints a focus border unconditionally, which restyles every settings page rather than only the focused row'
import os
import re
import sys
settings = sys.argv[1]
names = ["SliderRow", "ToggleRow", "SwitchRow", "ActionRow", "ChoiceRow",
"SegmentRow", "SettingRow", "OptionPickerRow", "PickerRow"]
for name in names:
text = re.sub(r"//.*", "", open(os.path.join(settings, name + ".qml"), encoding="utf-8").read())
for match in re.finditer(r"border\.(width|color):\s*([^\n]+)", text):
value = match.group(2)
if "activeFocus" in value or "focus" in value.lower():
continue
# A constant border on a focus-named element is the failure; borders
# that were always there (the card edge) are not.
line_start = text.rfind("\n", 0, match.start())
window = text[max(0, line_start - 400):match.start()]
if re.search(r"id:\s*focus", window, re.I):
raise SystemExit(1)
raise SystemExit(0)
PY
# ── 6. Reduce motion is true of the shell ────────────────────────────────────
#
# "Reduce motion" used to still the compositor's windows while the shell's own
# bar, dock, panels and OSD went on animating, which made the switch a
# half-truth for the surfaces most in front of you. Theme's duration tokens now
# collapse to zero, so every Behavior and NumberAnimation in the shell obeys it
# without knowing it exists.
grep -qE 'readonly property bool motionEnabled: *Settings\.animationsEnabled' "$theme" \
|| note 'Theme.motionEnabled no longer reads Settings.animationsEnabled, so Reduce motion has stopped reaching the shell'
for token in durFast durNormal durSlow durDockReveal; do
grep -qE "readonly property int ${token}: *motionEnabled \? [0-9]+ : 0" "$theme" \
|| note "Theme.$token does not collapse to 0 when motion is off, so Reduce motion is a half-truth again for whatever uses it"
done
# The switch itself is on the page, mirrored from Appearance.
page_has 'setting: "animationsEnabled"' \
|| note 'the Motion card does not carry the animations switch'
page_has 'setting: "visualAlerts"' \
|| note 'the Hearing card does not carry the visual alerts switch'
# ── 7. Findable ──────────────────────────────────────────────────────────────
#
# The schema covers the switches by their own labels. These are the words
# people arrive with that no label uses.
for entry in 'Zoom in and out' 'Reduce motion' 'Visual alerts' 'Screen reader' \
'Orca' 'Sticky keys'; do
grep -Fq "label: \"$entry\"" "$search" \
|| note "\"$entry\" is not in the search index, so typing it finds nothing on a desktop that has an answer for it"
done
python3 - "$search" <<'PY' || note 'an accessibility search entry routes somewhere other than the accessibility page'
import re
import sys
source = open(sys.argv[1], encoding="utf-8").read()
wanted = {"Zoom in and out", "Reduce motion", "Visual alerts", "Screen reader",
"Orca", "Sticky keys", "Magnifier zoom"}
for match in re.finditer(r'\{ label: "([^"]+)", detail: "[^"]*", page: "([a-z-]+)" \}', source):
label, page = match.groups()
if label in wanted and page != "accessibility":
print(f"{label} routes to {page}", file=sys.stderr)
raise SystemExit(1)
raise SystemExit(0)
PY
# High contrast is deliberately NOT an extra entry: it is a schema label, and
# the index already covers every schema label. A second copy would show the
# same setting twice in one result list.
grep -Fq 'label: "High contrast", detail:' "$search" \
&& note 'High contrast was added to the extra entries, but it is already a schema label -- the index would show it twice'
if (( ${#findings[@]} > 0 )); then
printf 'accessibility contract: %d finding(s)\n' "${#findings[@]}" >&2
printf ' - %s\n' "${findings[@]}" >&2
exit 1
fi
printf 'accessibility contract: PASS\n'