Make every settings row reachable, and every accessibility switch honest
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Executable
+889
@@ -0,0 +1,889 @@
|
||||
#!/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'
|
||||
Reference in New Issue
Block a user