Make every settings row reachable, and every accessibility switch honest

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 21:03:36 -04:00
parent e1ff25fc66
commit 9ffaf45a4d
33 changed files with 2384 additions and 76 deletions
+1 -1
View File
@@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work
## Tests ## Tests
174 of them, under `tests/`. Run the lot, or a subset by pattern: 175 of them, under `tests/`. Run the lot, or a subset by pattern:
```sh ```sh
panama test # everything panama test # everything
+10 -2
View File
@@ -119,8 +119,16 @@ a polished general-purpose desktop can go beyond the current shell.
3. A keyboard-layout change notice. Hyprland reports the active keymap but not 3. A keyboard-layout change notice. Hyprland reports the active keymap but not
a change event Quickshell already consumes, so this needs either polling or a change event Quickshell already consumes, so this needs either polling or
new event plumbing, and a single-layout machine cannot test it. new event plumbing, and a single-layout machine cannot test it.
4. Sticky keys, slow keys and bounce keys. AccessX is an X11 server feature 4. Sticky keys, slow keys and bounce keys. Wayland has no protocol for these,
with no Wayland equivalent; GNOME, macOS and Windows all ship these. so each compositor implements them for itself — mutter does, which is how
GNOME has them on Wayland, and Hyprland does not. There is no XKB option to
lean on either: the accessx option group is X11-only and does not appear in
evdev.lst at all (checked, not assumed — `grep -c accessx
/usr/share/X11/xkb/rules/evdev.lst` is 0), and Hyprland will happily store
`accessx:enable` as a keyboard option that nothing ever acts on. This is a
Hyprland gap rather than a Wayland impossibility, and the Accessibility page
says so in those terms; an earlier version of this line blamed X11 and sent
anyone who needs sticky keys to the wrong conclusion about the platform.
5. An on-screen keyboard, for a touch or convertible machine. 5. An on-screen keyboard, for a touch or convertible machine.
KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain
+38
View File
@@ -262,6 +262,44 @@ bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd(qs("screen-intelligence", "open")),
-- Color picker: copies the hex under the cursor to the clipboard. -- Color picker: copies the hex under the cursor to the clipboard.
bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" }) bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("hyprpicker -a -f hex"), { description = "Color picker" })
-- ── Magnifier ───────────────────────────────────────────────────────────────
category("Shell")
--
-- The chords are NOT the obvious SUPER+=/-/0. SUPER+equal is already "Reset
-- split" (Window management, below), and taking a daily tiling key away to
-- give the magnifier the prettiest chord on the keyboard is the wrong trade.
--
-- SUPER+ALT is where they went instead, which is also where GNOME's magnifier
-- lives: gsettings' magnifier-zoom-in / magnifier-zoom-out ship as
-- <Alt><Super>= and <Alt><Super>-, so this is the shortcut the machine this
-- desktop replaced already had. SUPER+ALT+0 -- free; the workspace digits are
-- plain ALT -- resets to 1.00 ×, reading as "back to zero magnification".
--
-- These go THROUGH the shell rather than calling `hyprctl keyword
-- cursor:zoom_factor` directly. Setting the compositor option behind Panama's
-- back would leave the stored preference and the Magnifier slider claiming a
-- magnification that is not the one on screen; the IPC call commits through
-- the same verified-preference path the slider uses, so the store, the
-- compositor and the settings page can never disagree. It also posts the OSD,
-- which is the only way to see what the factor now is with the pointer
-- somewhere else entirely.
--
-- Not `repeating`: the step is multiplicative (×1.25), so a held key repeating
-- at the keyboard rate would arrive at the 5.00 × ceiling in about a tenth of
-- a second. One press, one step.
--
-- Written as literal chords rather than `mod .. " + ALT + ..."` (as
-- "SUPER + Backspace" already is, above) because these three are the most
-- collision-prone binds in the file -- they were placed around one -- and a
-- literal is the form both the duplicate-chord check and the settings page's
-- chord display can actually read.
bind("SUPER + ALT + equal", hl.dsp.exec_cmd(qs("accessibility", "zoom in")),
{ description = "Zoom in" })
bind("SUPER + ALT + minus", hl.dsp.exec_cmd(qs("accessibility", "zoom out")),
{ description = "Zoom out" })
bind("SUPER + ALT + 0", hl.dsp.exec_cmd(qs("accessibility", "zoom reset")),
{ description = "Reset zoom" })
-- ── Window management ─────────────────────────────────────────────────────── -- ── Window management ───────────────────────────────────────────────────────
category("Windows") category("Windows")
bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" })
@@ -1022,9 +1022,14 @@ Singleton {
// this app refuses to ship. // this app refuses to ship.
{ {
key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1, key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1,
group: "accessibility", unit: "×", group: "accessibility",
label: "Magnifier", label: "Magnifier",
detail: "Magnifies the screen around the pointer. 1.0 is off", // The readout is "1.00 ×", so the detail says "1.00 ×" too. It used
// to say "1.0 is off" beside a slider reading 1.00, and the page
// carried a `zeroLabel: "Off"` that could never fire: the minimum
// IS 1.0, so the value is never 0 and the zero label was dead copy.
// Off is a magnification of one, and that is what both lines say.
detail: "Magnifies the screen around the pointer. 1.00 × is off",
hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" } hypr: { path: ["cursor", "zoom_factor"], option: "cursor:zoom_factor", readAs: "float" }
}, },
{ {
@@ -1054,6 +1059,16 @@ Singleton {
detail: "How much darker unfocused windows are", detail: "How much darker unfocused windows are",
hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" } hypr: { path: ["decoration", "dim_strength"], option: "decoration:dim_strength", readAs: "float" }
}, },
{
key: "visualAlerts", type: "bool", def: false, group: "accessibility",
label: "Flash the screen for notifications",
// No hypr mapping and no gsettings mapping: the flash is drawn by
// modules/notifications/VisualBell.qml, one per screen, and fires
// on the same notifications the bell would ring for -- except that
// it is deliberately NOT gated on the event-sounds switch, since a
// visual alert exists for people who cannot hear the bell.
detail: "A single flash at the edges of every screen when a notification arrives that would ring the bell"
},
// ── Gaming ────────────────────────────────────────────────────────── // ── Gaming ──────────────────────────────────────────────────────────
// What Panama does while a game runs. gamemode tells us when that // What Panama does while a game runs. gamemode tells us when that
+12
View File
@@ -86,6 +86,18 @@ Singleton {
// services/Notifs.qml. Off means Do Not Disturb is absolute. // services/Notifs.qml. Off means Do Not Disturb is absolute.
readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough") readonly property bool criticalBreaksThrough: DesktopPreferences.get("criticalBreaksThrough")
// ── Accessibility ───────────────────────────────────────────────────────
// Reduce motion. Theme.qml turns this into the dur* tokens, so every
// Behavior and NumberAnimation in the shell obeys it without knowing it
// exists. Read through here rather than from the store directly because
// Theme reads it on every animated property in the shell.
readonly property bool animationsEnabled: DesktopPreferences.get("animationsEnabled")
// Read on every notification that would ring the bell, by the per-screen
// VisualBell overlay. Lives here rather than being read from the store
// directly, like every other value the shell consults at speed.
readonly property bool visualAlerts: DesktopPreferences.get("visualAlerts")
// ── Sound ─────────────────────────────────────────────────────────────── // ── Sound ───────────────────────────────────────────────────────────────
// Over-amplification is the clamp ceiling for output volume: off means 1.0, // Over-amplification is the clamp ceiling for output volume: off means 1.0,
// on means 1.5. Every slider and the volume keys read the same switch, so // on means 1.5. Every slider and the volume keys read the same switch, so
+11 -4
View File
@@ -204,14 +204,21 @@ Singleton {
// ── Motion ────────────────────────────────────────────────────────────── // ── Motion ──────────────────────────────────────────────────────────────
// Event-driven only. Nothing in this shell animates while idle — no pulse, // Event-driven only. Nothing in this shell animates while idle — no pulse,
// no shimmer, no spinners. These durations are used for open/close/hover. // no shimmer, no spinners. These durations are used for open/close/hover.
readonly property int durFast: 120 //
readonly property int durNormal: 200 // All of them collapse to zero when Reduce motion is on: the Accessibility
readonly property int durSlow: 320 // toggle used to still the compositor's windows while the shell's own bar,
// dock and panels kept moving, which made it a half-truth. A duration of 0
// is a completed animation, so every Behavior and NumberAnimation in the
// shell obeys the switch without knowing it exists.
readonly property bool motionEnabled: Settings.animationsEnabled
readonly property int durFast: motionEnabled ? 120 : 0
readonly property int durNormal: motionEnabled ? 200 : 0
readonly property int durSlow: motionEnabled ? 320 : 0
// The dock revealing is the one animation that answers a live pointer // The dock revealing is the one animation that answers a live pointer
// movement, so it gets its own (much shorter) duration. Anything slower // movement, so it gets its own (much shorter) duration. Anything slower
// reads as the desktop lagging behind the cursor rather than as motion. // reads as the desktop lagging behind the cursor rather than as motion.
readonly property int durDockReveal: 90 readonly property int durDockReveal: motionEnabled ? 90 : 0
// Matches the "snappy" spring curve defined in hypr/looks.lua. // Matches the "snappy" spring curve defined in hypr/looks.lua.
readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0] readonly property list<real> easeStandard: [0.05, 0.9, 0.1, 1.0]
@@ -0,0 +1,168 @@
// The visual bell: one flash at the edges of the screen when a notification
// arrives that would have rung.
//
// For people who cannot hear the bell. It fires on exactly the notifications
// services/Notifs.qml calls bell-eligible -- same per-application switch, same
// low-urgency rule, same suppress-sound hint -- but NOT on the event-sounds
// switch, which would make this do nothing for the person it is for. That rule
// is pinned in Notifs.qml above `bellWouldRing`; this file only listens.
//
// Edges rather than the whole screen. A full-screen white flash is what X11's
// visual bell did, and it is genuinely unpleasant: it destroys dark adaptation,
// hides the thing you were reading at the moment it demands attention, and is
// the shape of flash that photosensitivity guidance warns about. A soft glow
// inward from the four edges is unmissable in peripheral vision and leaves the
// middle of the screen -- the part being read -- alone.
//
// ONE animation per notification. There is no `loops`, no Timer that restarts
// it, and a burst of notifications cannot stack flashes: while the animation is
// running, further triggers are ignored outright. A strobing screen is a
// seizure risk, not a notification.
import Quickshell
import Quickshell.Wayland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: root
property var modelData: null
screen: root.modelData
// Mapped only while flashing. The rest of the session this costs nothing,
// and no surface sits over the desktop waiting for something to happen.
property bool mapped: false
visible: root.mapped
anchors.top: true
anchors.bottom: true
anchors.left: true
anchors.right: true
// Reserve nothing and respect nothing: the glow is drawn over the whole
// output including under the bar and the dock, which is what makes it
// visible from wherever the eyes happen to be.
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
color: "transparent"
WlrLayershell.namespace: "qs-visual-bell"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
// Entirely click-through: an empty mask means no part of this surface
// takes a pointer event, so a flash cannot swallow the click you were in
// the middle of making.
mask: Region {}
// How far the glow reaches in from each edge. Fixed pixels rather than a
// share of the screen: this is about peripheral vision, which does not
// scale with the size of the monitor.
readonly property int reach: 72
// Deliberately NOT Theme.durFast / Theme.durNormal. Those collapse to zero
// when Reduce motion is on, which would make the flash instantaneous and
// therefore invisible -- switching on Reduce motion would silently switch
// off Visual alerts. A flash is information, not decoration, so it keeps
// its own timings. They are slow enough not to strobe and quick enough to
// be over before it becomes irritating.
readonly property int riseMs: 110
readonly property int fallMs: 340
Connections {
target: Notifs
function onBellEligible(notification: var): void { root.flash(); }
}
// The one-shot. A trigger arriving mid-flash is dropped rather than
// queued or restarted, so ten notifications landing together are one
// flash -- the same coalescing the audible bell gets from its throttle.
function flash(): void {
if (!Settings.visualAlerts || pulse.running)
return;
root.mapped = true;
pulse.restart();
}
Item {
id: glow
anchors.fill: parent
opacity: 0
readonly property color tint: Theme.alpha(Theme.accent, 0.62)
readonly property color fade: Theme.alpha(Theme.accent, 0)
Rectangle {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: root.reach
gradient: Gradient {
GradientStop { position: 0.0; color: glow.tint }
GradientStop { position: 1.0; color: glow.fade }
}
}
Rectangle {
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
height: root.reach
gradient: Gradient {
GradientStop { position: 0.0; color: glow.fade }
GradientStop { position: 1.0; color: glow.tint }
}
}
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: root.reach
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: glow.tint }
GradientStop { position: 1.0; color: glow.fade }
}
}
Rectangle {
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
width: root.reach
gradient: Gradient {
orientation: Gradient.Horizontal
GradientStop { position: 0.0; color: glow.fade }
GradientStop { position: 1.0; color: glow.tint }
}
}
}
// Up, down, gone. One sequence, run once per notification: no `loops`, no
// repeat, and the window unmaps itself at the end so nothing is left over
// the desktop between notifications.
SequentialAnimation {
id: pulse
NumberAnimation {
target: glow
property: "opacity"
from: 0
to: 1
duration: root.riseMs
easing.type: Easing.OutCubic
}
NumberAnimation {
target: glow
property: "opacity"
to: 0
duration: root.fallMs
easing.type: Easing.InCubic
}
ScriptAction { script: root.mapped = false }
}
}
@@ -26,6 +26,11 @@ function iconFor(kind, ratio) {
return "audio-input-microphone-symbolic"; return "audio-input-microphone-symbolic";
if (name === "brightness") if (name === "brightness")
return "display-brightness-symbolic"; return "display-brightness-symbolic";
// The magnifier, stepped from SUPER+ALT+= / - / 0. Ratio 0 is 1.00 ×,
// which is the magnifier switched off, so it gets the "actual size" icon
// rather than a magnifying glass claiming to be magnifying.
if (name === "zoom")
return ratio <= 0 ? "zoom-original-symbolic" : "zoom-in-symbolic";
if (name === "media-play" || name === "media-playing") if (name === "media-play" || name === "media-playing")
return "media-playback-start-symbolic"; return "media-playback-start-symbolic";
if (name === "media-pause" || name === "media-paused") if (name === "media-pause" || name === "media-paused")
@@ -1,5 +1,15 @@
// Accessibility. // Accessibility.
// //
// Organised by what a person came here unable to do -- see it, tolerate the
// motion, hear it, reach it from the keyboard, have it read aloud -- rather
// than by which subsystem happens to implement each control. The old page was
// grouped by mechanism (Pointer / Text / Motion / Magnifier / Contrast), which
// is the shape of the code and not the shape of the question.
//
// Everything on this page acts on this session. Two things deliberately do not
// ship as switches -- mono audio, and sticky/slow/bounce keys -- and both say
// why in place instead of being quietly absent or, worse, present and dead.
//
// Pointer size and text scale have to agree across three consumers that share // Pointer size and text scale have to agree across three consumers that share
// no configuration system -- the compositor, GTK applications, and the shell. // no configuration system -- the compositor, GTK applications, and the shell.
// Panama's store is the source of truth and services/Accessibility.qml pushes // Panama's store is the source of truth and services/Accessibility.qml pushes
@@ -13,75 +23,205 @@ SettingsPage {
id: root id: root
title: "Accessibility" title: "Accessibility"
lede: "Make the desktop easier to see and easier to hit." lede: "Every switch on this page does something on this desktop — and the ones that cannot yet say why."
SettingsCard { // The zoom chords come from the compositor's live keymap, matched on the
title: "Pointer" // descriptions hypr/keybinds.lua gives them, so rebinding a zoom key
subtitle: "Applied to the compositor and to applications at the same time." // changes what this page says instead of quietly making it wrong. The
// literals are the shipped chords, standing in only until the keymap has
// loaded -- not a second source of truth.
function chordFor(needle: string, fallback: string): string {
for (const bind of Keybinds.binds) {
if (String(bind.description).toLowerCase().indexOf(needle) >= 0)
return String(bind.chord);
}
return fallback;
}
SliderRow { setting: "cursorSize" } readonly property string zoomInChord: root.chordFor("zoom in", "Super + Alt + =")
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false } readonly property string zoomOutChord: root.chordFor("zoom out", "Super + Alt + -")
readonly property string zoomResetChord: root.chordFor("reset zoom", "Super + Alt + 0")
// Probed when the page opens rather than polled all session: nothing else
// on this desktop needs to know whether Orca is running.
Component.onCompleted: Accessibility.refreshScreenReader()
readonly property string screenReaderDetail: {
if (Accessibility.orcaRunning)
return "Running — reading the focused application";
if (Accessibility.accessibilityBusRunning)
return "Not running · the accessibility bus is up, so applications are ready to be read";
return "Not running · the accessibility bus is not up, so Orca would start and read nothing";
} }
SettingsCard { SettingsCard {
title: "Text" title: "Vision"
subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected." subtitle: "Magnification is the compositor's own, so it follows the pointer across every window and every screen."
SliderRow { setting: "magnifierFactor" }
// One row per chord rather than three chords crammed into one row's
// trailing slot: three keycap chords side by side are wider than the
// control column, and squeezing them there costs the label its line.
SettingRow {
label: "Zoom in"
detail: "Works from anywhere — the OSD shows the magnification you land on"
controlWidth: 210
KeycapChord {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
chord: root.zoomInChord
}
}
SettingRow {
label: "Zoom out"
controlWidth: 210
KeycapChord {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
chord: root.zoomOutChord
}
}
SettingRow {
label: "Back to 1.00 ×"
detail: "Turns the magnifier off without coming back to Settings for it"
controlWidth: 210
KeycapChord {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
chord: root.zoomResetChord
}
}
ToggleRow { setting: "magnifierRigid" }
SliderRow { setting: "textScale" } SliderRow { setting: "textScale" }
// Reaches GTK4 applications through the desktop portal, which SliderRow { setting: "cursorSize" }
// republishes it as org.freedesktop.appearance contrast. No
// high-contrast theme is involved, and none is installed here -- older
// GTK3 applications will not change.
ToggleRow { setting: "highContrast"; divider: false } ToggleRow { setting: "highContrast"; divider: false }
} }
SettingsCard { SettingsCard {
title: "Motion" title: "Motion"
subtitle: "Nothing animates while idle. This affects motion you asked for — windows opening, workspaces sliding, panels appearing." subtitle: "Nothing animates while idle. This is the motion you asked for — windows opening, workspaces sliding, panels appearing."
ToggleRow { setting: "animationsEnabled"; divider: false } // The detail says more than the schema's does because on this page the
// claim is the point: the shell's own durations now collapse to zero
// when this is off, so the bar, the dock and the panels genuinely stop
// moving. Until that landed, this switch reached the compositor and
// left the shell animating over the top of it.
ToggleRow {
setting: "animationsEnabled"
detail: "Window, workspace and panel motion — including the shell's own bar, dock and panels, which now stop with everything else"
} }
// Zoom, done by the compositor rather than handed to GNOME. Hyprland has a
// real magnifier (cursor:zoom_factor) that follows the pointer, so there is
// no reason to send someone to another application for it.
SettingsCard {
title: "Magnifier"
subtitle: "Magnifies the screen around the pointer. Set the magnification to 1× to turn it off."
SliderRow { setting: "magnifierFactor"; zeroLabel: "Off" }
ToggleRow { setting: "magnifierRigid"; divider: false }
}
SettingsCard {
title: "Contrast"
subtitle: "Unfocused windows can be faded or darkened to make the focused one obvious, or left alone if that is harder to read."
SliderRow { setting: "inactiveOpacity" }
ToggleRow { setting: "dimInactive" } ToggleRow { setting: "dimInactive" }
SliderRow { setting: "dimStrength"; divider: false }
// The amount only means anything while dimming is on, so it goes quiet
// rather than disappearing: a row that vanishes takes the explanation
// of what the switch above it does with it.
SliderRow {
setting: "dimStrength"
enabled: DesktopPreferences.get("dimInactive") === true
opacity: enabled ? 1 : 0.4
}
SliderRow { setting: "inactiveOpacity"; divider: false }
} }
// What this session genuinely cannot do, said plainly -- and for the
// right reason. On Wayland there is no protocol for sticky, slow or
// bounce keys: each compositor implements its own (mutter does, which is
// how GNOME has them on Wayland), and Hyprland does not yet. An earlier
// version blamed "X11 feature with no Wayland equivalent", which sent
// anyone who needs sticky keys to the wrong conclusion about the whole
// platform. There is also deliberately no handoff to GNOME's
// universal-access panel: its toggles are applied by GNOME Shell, and
// the few that work through plain gsettings (cursor size, text scale,
// high contrast) are owned by the controls above on this very page.
SettingsCard { SettingsCard {
title: "Keyboard accessibility" title: "Hearing"
subtitle: "Sticky, slow and bounce keys are implemented by each Wayland compositor for itself; Hyprland does not implement them yet, so they are unavailable in this session. Offering switches here would store preferences nothing acts on." subtitle: "What the desktop does instead of making a sound."
ToggleRow { setting: "visualAlerts" }
SettingRow {
label: "Mono audio"
detail: "Not offered yet — folding stereo into one channel is a real change to the PipeWire graph, and a switch that only looked like it did that would be worse than its absence. Balance lives on the Sound page meanwhile."
controlWidth: 90
divider: false
SoundBadge {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Not yet"
tone: Theme.fgMuted
}
}
}
SettingsCard {
title: "Keyboard & pointer"
subtitle: "Key behaviour lives with the Keyboard settings; what the pointer does when you stop moving it lives here."
ActionRow { ActionRow {
label: "Screen reader" label: "Key repeat"
detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here" detail: "Delay and speed are on the Keyboard page, with the rest of the keymap"
action: "Start Orca" action: "Open Keyboard"
onTriggered: ShellState.openSettings("shortcuts")
}
SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false }
// The card's rows sit flush against each other; the note is a
// separate thing and needs the gap to read as one.
Item { width: 1; height: 10 }
// The receipts, not a warning. See SettingsNote for why this is quiet.
SettingsNote {
headline: "Sticky, slow and bounce keys are not offered"
body: "Hyprland has no such options — asked of the running compositor rather than assumed — and GNOME's switches for them are applied by a daemon this session does not run, so a switch here would be wired to nothing. On Wayland each compositor implements these for itself; if Hyprland grows them, they land on this page."
}
}
SettingsCard {
title: "Screen reader"
subtitle: "Orca is a separate application and is reported as one: either its process is running or it is not."
SettingRow {
label: "Orca"
detail: root.screenReaderDetail
controlWidth: 200
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 9
SoundBadge {
anchors.verticalCenter: parent.verticalCenter
text: Accessibility.orcaRunning ? "Running" : "Stopped"
tone: Accessibility.orcaRunning ? Theme.ok : Theme.fgMuted
}
SettingsButton {
anchors.verticalCenter: parent.verticalCenter
text: Accessibility.orcaRunning ? "Stop" : "Start"
onClicked: {
if (Accessibility.orcaRunning)
Accessibility.stopOrca();
else
Accessibility.startOrca();
}
}
}
}
SettingRow {
label: "This app, read aloud"
detail: "Every settings row carries a spoken name and takes keyboard focus — Tab walks the page, Space flips a switch, the arrows move a slider or step a choice"
controlWidth: 90
divider: false divider: false
onTriggered: SystemSettings.openApplication("orca")
SoundBadge {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: "Built in"
tone: Theme.ok
}
} }
} }
@@ -23,6 +23,11 @@ SettingRow {
controlWidth: Math.max(110, button.implicitWidth + 8) controlWidth: Math.max(110, button.implicitWidth + 8)
function press(): void {
if (root.enabled)
root.triggered();
}
SettingsButton { SettingsButton {
id: button id: button
anchors.right: parent.right anchors.right: parent.right
@@ -30,5 +35,33 @@ SettingRow {
text: root.action text: root.action
enabled: root.enabled enabled: root.enabled
onClicked: root.triggered() onClicked: root.triggered()
activeFocusOnTab: root.enabled
// The row's label is what the button is FOR; the button's own caption
// is what pressing it does. Both are said, in that order.
Accessible.role: Accessible.Button
Accessible.name: root.label
Accessible.description: root.detail === ""
? root.action
: `${root.detail} ${root.action}`
Accessible.focusable: root.enabled
Accessible.focused: button.activeFocus
Accessible.onPressAction: root.press()
Keys.onReturnPressed: root.press()
Keys.onEnterPressed: root.press()
Keys.onSpacePressed: root.press()
// Focus ring only -- the button keeps its own border at rest.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 11
color: "transparent"
visible: button.activeFocus
border.width: 2
border.color: Theme.accentSecondary
}
} }
} }
@@ -24,7 +24,28 @@ SettingRow {
detail: root.spec ? root.spec.detail : "" detail: root.spec ? root.spec.detail : ""
controlWidth: Math.max(120, root.options.length * 92) controlWidth: Math.max(120, root.options.length * 92)
readonly property var currentOption:
root.options.find(option => option.value === root.current) ?? null
// Left and Right move one segment along and commit, which is what the
// segments already do under a click -- the arrows are just the other way to
// reach the same neighbour. It never wraps: running off the end of a
// segmented control silently landing on the far end is how a keyboard user
// sets something they did not mean to.
function stepChoice(delta: int): void {
if (root.options.length === 0)
return;
const at = root.options.findIndex(option => option.value === root.current);
const from = at < 0 ? 0 : at;
const next = Math.max(0, Math.min(root.options.length - 1, from + delta));
if (next === at)
return;
SystemSettings.commitPreference(root.setting, root.options[next].value);
}
Rectangle { Rectangle {
id: group
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
implicitWidth: segments.implicitWidth + 4 implicitWidth: segments.implicitWidth + 4
@@ -33,6 +54,35 @@ SettingRow {
color: Theme.alpha(Theme.fg, 0.07) color: Theme.alpha(Theme.fg, 0.07)
border.width: 0 border.width: 0
activeFocusOnTab: true
Accessible.role: Accessible.ComboBox
Accessible.name: root.label
Accessible.description: {
const chosen = root.currentOption
? String(root.currentOption.label ?? "")
: "nothing selected";
return root.detail === "" ? chosen : `${root.detail} ${chosen}`;
}
Accessible.focusable: true
Accessible.focused: group.activeFocus
Keys.onLeftPressed: root.stepChoice(-1)
Keys.onUpPressed: root.stepChoice(-1)
Keys.onRightPressed: root.stepChoice(1)
Keys.onDownPressed: root.stepChoice(1)
// Focus ring only -- the strip keeps its borderless plate at rest.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 11
color: "transparent"
visible: group.activeFocus
border.width: 2
border.color: Theme.accentSecondary
}
Row { Row {
id: segments id: segments
anchors.centerIn: parent anchors.centerIn: parent
@@ -54,6 +104,13 @@ SettingRow {
border.width: 0 border.width: 0
color: "transparent" color: "transparent"
// Named individually so the reader says which option it is
// on, not just that a choice exists. Not a Tab stop: the
// strip is one stop and the arrows walk it.
Accessible.role: Accessible.RadioButton
Accessible.name: String(segment.modelData.label ?? "")
Accessible.checked: segment.selected
// The selected segment is the only place the prism appears // The selected segment is the only place the prism appears
// in a row: blue leads into orchid, never orchid alone. // in a row: blue leads into orchid, never orchid alone.
Rectangle { Rectangle {
@@ -68,6 +68,18 @@ PickerRow {
controlWidth: 90 controlWidth: 90
divider: index < root.options.length - 1 divider: index < root.options.length - 1
activatable: modelData.value !== root.current activatable: modelData.value !== root.current
// An option in an open list, not a button: the reader should say
// which one is already chosen. The current option is deliberately
// not activatable and so not a Tab stop -- picking what is already
// picked is not a thing to walk to.
activeFocusOnTab: modelData.value !== root.current
Accessible.role: Accessible.RadioButton
Accessible.name: String(modelData.label ?? "")
Accessible.description: String(modelData.detail ?? "")
Accessible.checked: modelData.value === root.current
onActivated: { onActivated: {
root.picked(modelData.value); root.picked(modelData.value);
root.collapse(); root.collapse();
@@ -50,6 +50,17 @@ Column {
controlWidth: 210 controlWidth: 210
onActivated: root.expanded = !root.expanded onActivated: root.expanded = !root.expanded
// The collapsed row is already a Tab stop and already activates on
// Space through SettingRow; what it cannot say for itself is that it
// opens a list, and which value that list is currently sitting on.
activeFocusOnTab: root.enabled
Accessible.role: Accessible.ComboBox
Accessible.name: root.label
Accessible.description: root.detail === ""
? root.value
: `${root.detail} ${root.value}`
Row { Row {
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -335,6 +335,30 @@ erase a user-selected accent or a theme switch to leave the border behind.
| `ChoiceRow { setting }` | An enum, as a segmented control | | `ChoiceRow { setting }` | An enum, as a segmented control |
| `ActionRow` | A button: opens a GNOME panel, runs a one-shot | | `ActionRow` | A button: opens a GNOME panel, runs a one-shot |
| `TextRow` | A genuinely read-only fact | | `TextRow` | A genuinely read-only fact |
| `SettingsNote` | An inset paragraph: the explanation too long to be a `detail` |
`SettingsNote` is deliberately quiet and must stay that way. It explains
absences and boundaries — what this session cannot do, and why — and a
warning-coloured box would turn "not offered yet, here is the reason" into
"something is wrong here".
### The rows are keyboard-reachable
Every row primitive carries `Accessible.role`, an `Accessible.name` taken from
its label, and an `Accessible.description` taken from its detail; the
interactive element sets `activeFocusOnTab` and draws a two-pixel
`Theme.accentSecondary` ring **only** while it holds focus. Tab walks the page,
Space and Enter flip a switch or press a button, and Left/Right (and Up/Down)
step a slider by one schema step or move a segmented choice by one option,
never wrapping.
Two rules hold this together. An inert row is not a Tab stop: `SettingRow`
becomes focusable only when `activatable` is true, because Tab landing on
static text is how keyboard navigation stops being usable. And a keyboard
change commits through exactly the same path a pointer change does —
`SliderRow`'s arrows go through the same 140 ms debounce as a drag, so a held
arrow behaves like a drag rather than a burst of writes the compositor spends
the whole time rejecting.
`TextRow` is for facts, not for settings that were merely expensive to wire. `TextRow` is for facts, not for settings that were merely expensive to wire.
Before Stage 3 more than half of all rows were static text standing in for Before Stage 3 more than half of all rows were static text standing in for
@@ -18,12 +18,61 @@ SettingRow {
controlWidth: Math.max(150, root.options.length * 92) controlWidth: Math.max(150, root.options.length * 92)
readonly property var currentOption:
root.options.find(option => option.value === root.value) ?? null
// One segment along, never wrapping -- the same neighbour a click would
// have picked, asked for in the same way.
function stepChoice(delta: int): void {
if (!root.enabled || root.options.length === 0)
return;
const at = root.options.findIndex(option => option.value === root.value);
const from = at < 0 ? 0 : at;
const next = Math.max(0, Math.min(root.options.length - 1, from + delta));
if (next === at)
return;
root.selected(root.options[next].value);
}
// Sibling of the strip rather than a child of it: a Rectangle inside a Row
// would be laid out as one more segment.
Rectangle {
anchors.fill: strip
anchors.margins: -3
radius: 12
z: -1
color: "transparent"
visible: strip.activeFocus
border.width: 2
border.color: Theme.accentSecondary
}
Row { Row {
id: strip
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
spacing: 6 spacing: 6
opacity: root.enabled ? 1 : 0.45 opacity: root.enabled ? 1 : 0.45
activeFocusOnTab: root.enabled
Accessible.role: Accessible.ComboBox
Accessible.name: root.label
Accessible.description: {
const chosen = root.currentOption
? String(root.currentOption.label ?? "")
: "nothing selected";
return root.detail === "" ? chosen : `${root.detail} ${chosen}`;
}
Accessible.focusable: root.enabled
Accessible.focused: strip.activeFocus
Keys.onLeftPressed: root.stepChoice(-1)
Keys.onUpPressed: root.stepChoice(-1)
Keys.onRightPressed: root.stepChoice(1)
Keys.onDownPressed: root.stepChoice(1)
Repeater { Repeater {
model: root.options model: root.options
@@ -45,6 +94,12 @@ SettingRow {
? Theme.alpha(Theme.accent, 0.5) ? Theme.alpha(Theme.accent, 0.5)
: Theme.alpha(Theme.fg, 0.08) : Theme.alpha(Theme.fg, 0.08)
// Named individually so the reader says which option it is on.
// Not a Tab stop: the strip is one stop and the arrows walk it.
Accessible.role: Accessible.RadioButton
Accessible.name: String(segment.modelData.label ?? "")
Accessible.checked: segment.current
Text { Text {
id: segmentLabel id: segmentLabel
anchors.centerIn: parent anchors.centerIn: parent
@@ -18,9 +18,33 @@ Item {
property bool activatable: false property bool activatable: false
signal activated signal activated
// Keyboard and screen-reader reach, added without changing a single thing
// about how the row looks or behaves under the pointer.
//
// Only an activatable row becomes a Tab stop. A row that does nothing when
// clicked must not collect focus either -- Tab landing on inert text is how
// keyboard navigation stops being usable long before it stops working.
function activate(): void {
if (root.activatable)
root.activated();
}
width: parent ? parent.width : 620 width: parent ? parent.width : 620
implicitHeight: Math.max(56, copy.implicitHeight + 20) implicitHeight: Math.max(56, copy.implicitHeight + 20)
activeFocusOnTab: root.activatable
Accessible.role: root.activatable ? Accessible.Button : Accessible.StaticText
Accessible.name: root.label
Accessible.description: root.detail
Accessible.focusable: root.activatable
Accessible.focused: root.activeFocus
Accessible.onPressAction: root.activate()
Keys.onReturnPressed: root.activate()
Keys.onEnterPressed: root.activate()
Keys.onSpacePressed: root.activate()
Text { Text {
id: iconLabel id: iconLabel
anchors.left: parent.left anchors.left: parent.left
@@ -104,6 +128,20 @@ Item {
border.width: 0 border.width: 0
} }
// The focus ring: the hover plate's geometry with a border added, so a row
// reached by Tab reads as the same target the pointer would have hit. Drawn
// only while this row holds keyboard focus, so nothing changes at rest.
Rectangle {
anchors.fill: parent
anchors.bottomMargin: 1
radius: 8
z: -1
visible: root.activatable && root.activeFocus
color: Theme.alpha(Theme.fg, 0.05)
border.width: 2
border.color: Theme.accentSecondary
}
HoverHandler { HoverHandler {
id: rowHover id: rowHover
enabled: root.activatable enabled: root.activatable
@@ -0,0 +1,71 @@
// An inset paragraph inside a card: the explanation that is too long to be a
// row's `detail` and too important to be left out.
//
// SettingsNote {
// headline: "Sticky, slow and bounce keys are not offered"
// body: "Hyprland has no such options..."
// }
//
// Deliberately quiet. This is not a warning banner and must never grow one's
// colouring: the things it explains are absences and boundaries, not problems,
// and a red-edged box would make "this desktop cannot do that yet" read as
// "something is wrong here". It sits darker than the card it is in, which is
// the whole visual signal it needs -- a note, set into the surface.
import QtQuick
import qs.config
Rectangle {
id: root
// The claim, in one line. Said first because a reader who stops after it
// still leaves with the answer.
property string headline: ""
// Why the claim is true. This is where the receipts go.
property string body: ""
width: parent ? parent.width : 620
implicitHeight: copy.implicitHeight + 22
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.5)
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.08)
// Read out as one passage rather than as two unrelated fragments.
Accessible.role: Accessible.StaticText
Accessible.name: root.headline
Accessible.description: root.body
Column {
id: copy
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 13
anchors.rightMargin: 13
spacing: 4
Text {
width: parent.width
visible: root.headline !== ""
text: root.headline
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
wrapMode: Text.WordWrap
}
Text {
width: parent.width
visible: root.body !== ""
text: root.body
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
lineHeight: 1.35
wrapMode: Text.WordWrap
}
}
}
@@ -81,6 +81,22 @@ Item {
return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped; return root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
} }
// One schema step, from the arrow keys, committed through the same debounce
// the pointer drag uses -- holding an arrow reads as a drag rather than as
// a burst of writes the compositor would spend the whole time rejecting.
function nudge(steps: int): void {
const raw = root.shown + steps * root.step;
const clamped = Math.max(root.minimum, Math.min(root.maximum, raw));
root.pending = root.step < 1 ? Math.round(clamped * 100) / 100 : clamped;
commitTimer.restart();
}
// What a screen reader is told the slider is sitting on. Qt 6.11's attached
// Accessible type carries no structured value or range, so the reading and
// its bounds go in the description, in the row's own display units.
readonly property string reading:
`${root.display(root.shown)}, ${root.display(root.minimum)} to ${root.display(root.maximum)}`
function display(value: real): string { function display(value: real): string {
if (value === 0 && root.zeroLabel !== "") if (value === 0 && root.zeroLabel !== "")
return root.zeroLabel; return root.zeroLabel;
@@ -145,6 +161,35 @@ Item {
root.pending = root.quantise(ratio); root.pending = root.quantise(ratio);
commitTimer.restart(); commitTimer.restart();
} }
activeFocusOnTab: true
Accessible.role: Accessible.Slider
Accessible.name: root.label
Accessible.description: root.detail === ""
? root.reading
: `${root.detail} ${root.reading}`
Accessible.focusable: true
Accessible.focused: slider.activeFocus
Accessible.onIncreaseAction: root.nudge(1)
Accessible.onDecreaseAction: root.nudge(-1)
Keys.onLeftPressed: root.nudge(-1)
Keys.onDownPressed: root.nudge(-1)
Keys.onRightPressed: root.nudge(1)
Keys.onUpPressed: root.nudge(1)
// Drawn only while the slider holds keyboard focus: transparent
// fill, so at rest there is nothing here at all.
Rectangle {
anchors.fill: parent
anchors.margins: -2
radius: 9
color: "transparent"
visible: slider.activeFocus
border.width: 2
border.color: Theme.accentSecondary
}
} }
Text { Text {
@@ -17,7 +17,16 @@ SettingRow {
controlWidth: 54 controlWidth: 54
// Space and Enter do what a click does: ask for the other state and let
// whoever owns that state decide, exactly as the pointer path does.
function flip(): void {
if (root.enabled)
root.toggled(!root.checked);
}
Rectangle { Rectangle {
id: pill
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: 44 width: 44
@@ -26,6 +35,31 @@ SettingRow {
color: root.checked ? Theme.accent : Theme.alpha(Theme.fg, 0.14) color: root.checked ? Theme.accent : Theme.alpha(Theme.fg, 0.14)
opacity: root.enabled ? 1 : 0.45 opacity: root.enabled ? 1 : 0.45
activeFocusOnTab: root.enabled
Accessible.role: Accessible.CheckBox
Accessible.name: root.label
Accessible.description: root.detail
Accessible.checked: root.checked
Accessible.focusable: root.enabled
Accessible.focused: pill.activeFocus
Accessible.onPressAction: root.flip()
Keys.onReturnPressed: root.flip()
Keys.onEnterPressed: root.flip()
Keys.onSpacePressed: root.flip()
// Focus ring only -- nothing is drawn here at rest.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: height / 2
color: "transparent"
visible: pill.activeFocus
border.width: 2
border.color: Theme.accentSecondary
}
Rectangle { Rectangle {
x: root.checked ? parent.width - width - 3 : 3 x: root.checked ? parent.width - width - 3 : 3
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -24,10 +24,43 @@ SettingRow {
detail: root.spec ? root.spec.detail : "" detail: root.spec ? root.spec.detail : ""
controlWidth: 48 controlWidth: 48
// Space and Enter write the same value the pointer would, through the same
// verified path -- there is no second way to flip a preference here.
function flip(): void {
SystemSettings.commitPreference(root.setting, !root.checked);
}
SettingsToggle { SettingsToggle {
id: toggle
anchors.right: parent.right anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
checked: root.checked checked: root.checked
onToggled: value => SystemSettings.commitPreference(root.setting, value) onToggled: value => SystemSettings.commitPreference(root.setting, value)
activeFocusOnTab: true
Accessible.role: Accessible.CheckBox
Accessible.name: root.label
Accessible.description: root.detail
Accessible.checked: root.checked
Accessible.focusable: true
Accessible.focused: toggle.activeFocus
Accessible.onPressAction: root.flip()
Keys.onReturnPressed: root.flip()
Keys.onEnterPressed: root.flip()
Keys.onSpacePressed: root.flip()
// Focus ring only -- the switch keeps its own border at rest.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: height / 2
color: "transparent"
visible: toggle.activeFocus
border.width: 2
border.color: Theme.accentSecondary
}
} }
} }
@@ -35,6 +35,7 @@ HealthCheckRow 1.0 HealthCheckRow.qml
SegmentRow 1.0 SegmentRow.qml SegmentRow 1.0 SegmentRow.qml
SettingRow 1.0 SettingRow.qml SettingRow 1.0 SettingRow.qml
SettingsCard 1.0 SettingsCard.qml SettingsCard 1.0 SettingsCard.qml
SettingsNote 1.0 SettingsNote.qml
SettingsButton 1.0 SettingsButton.qml SettingsButton 1.0 SettingsButton.qml
SettingsShell 1.0 SettingsShell.qml SettingsShell 1.0 SettingsShell.qml
SettingsSidebar 1.0 SettingsSidebar.qml SettingsSidebar 1.0 SettingsSidebar.qml
@@ -1,6 +1,6 @@
pragma Singleton pragma Singleton
// Pointer size and text scale. // Pointer size, text scale, the magnifier, and the screen reader.
// //
// These are the two settings that must agree across three consumers that do not // These are the two settings that must agree across three consumers that do not
// share a configuration system: the compositor draws the cursor, GTK // share a configuration system: the compositor draws the cursor, GTK
@@ -16,6 +16,12 @@ pragma Singleton
// reflow every panel against layouts that were tuned at the design size. Text // reflow every panel against layouts that were tuned at the design size. Text
// scale therefore affects applications, which is where it matters, and the // scale therefore affects applications, which is where it matters, and the
// page says so rather than implying it does more. // page says so rather than implying it does more.
//
// The magnifier and Orca are here too, because they are the other two things
// the Accessibility page can actually do something about. Both are deliberately
// indirect: the magnifier goes through SystemSettings.commitPreference so the
// stored value and the compositor stay the same value, and Orca is started and
// stopped as a process because there is no working desktop switch for it.
import Quickshell import Quickshell
import Quickshell.Io import Quickshell.Io
@@ -93,4 +99,214 @@ Singleton {
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]); commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
root.enqueue(commands); root.enqueue(commands);
} }
// ── The magnifier ───────────────────────────────────────────────────────
//
// Hyprland's own zoom (cursor:zoom_factor), stepped from the keyboard.
//
// The step is multiplicative rather than additive. Magnification is
// perceived as a ratio -- 1.00 -> 1.25 and 4.00 -> 5.00 are the same
// apparent jump -- so a fixed +0.1 would crawl at the bottom of the range,
// where somebody switching the magnifier on actually lives, and lurch at
// the top. Four presses from off reach 2.44 ×, which is roughly where a
// person reaching for a magnifier wants to be.
//
// Rounded to two decimals because that is exactly what the Magnifier slider
// displays and stores; leaving 1.953125 in the settings file would make the
// slider and the keyboard disagree about the same number.
readonly property real zoomStep: 1.25
readonly property real magnifierFactor: DesktopPreferences.get("magnifierFactor")
// What the keys have asked for, which can be a beat ahead of the store.
//
// commitPreference applies the option to the compositor and waits for
// Hyprland to confirm it before storing anything, so for a few tens of
// milliseconds after a press the stored value is still the old one. Two
// quick presses reading it would both compute the same step and the second
// would be swallowed -- which is precisely how these keys get used, in
// threes and fours. Stepping from the intent instead makes a burst of
// presses arrive where the fingers expected.
//
// -1 means "no press outstanding, the store is the truth". Cleared when the
// store catches up, and unconditionally a moment later so a commit Hyprland
// refused cannot leave the keys stepping from a magnification that never
// happened.
property real zoomIntent: -1
readonly property real requestedZoom: root.zoomIntent > 0 ? root.zoomIntent : root.magnifierFactor
onMagnifierFactorChanged: {
if (root.magnifierFactor === root.zoomIntent)
root.zoomIntent = -1;
}
Timer {
id: zoomIntentExpiry
interval: 1500
onTriggered: root.zoomIntent = -1
}
// The keyboard's entry point, called from shell.qml's `accessibility` IPC
// target (SUPER+ALT+= / - / 0 in hypr/keybinds.lua).
//
// NEVER `hyprctl keyword cursor:zoom_factor`. That would change the screen
// without changing the preference, and the Magnifier slider would then be
// showing a magnification nobody is looking at. commitPreference is the one
// path that applies the option, verifies Hyprland took it, and only then
// stores it -- the same path the slider uses, so the two cannot drift.
//
// Returns a line for `qs ipc call`, so the shortcut can be tested from a
// terminal and say what it did.
function stepZoom(direction: string): string {
const spec = PreferenceSchema.spec("magnifierFactor");
const minimum = spec.min;
const maximum = spec.max;
const current = root.requestedZoom;
let next;
switch (direction) {
case "in":
next = current * root.zoomStep;
break;
case "out":
next = current / root.zoomStep;
break;
case "reset":
next = minimum;
break;
default:
return `Unknown zoom direction "${direction}". Use in, out or reset.`;
}
next = Math.max(minimum, Math.min(maximum, Math.round(next * 100) / 100));
// Nothing is written when the step lands where we already are -- at
// either end of the range, or on reset with the magnifier already off.
// The OSD still fires below, though: a key that appears to do nothing
// is indistinguishable from a broken one, and "5.00 ×" is the answer to
// why pressing it again did not help.
if (next !== current) {
if (!SystemSettings.commitPreference("magnifierFactor", next)) {
root.zoomIntent = -1;
root.lastError = SystemSettings.lastError || "The magnification could not be changed.";
return root.lastError;
}
root.zoomIntent = next;
zoomIntentExpiry.restart();
}
root.showZoomOsd(next);
return `Magnifier ${root.zoomLabel(next)}`;
}
// The same text the Magnifier slider shows, from the same schema unit, so
// the OSD and the settings page never word the same number differently.
function zoomLabel(factor: real): string {
const spec = PreferenceSchema.spec("magnifierFactor");
return `${factor.toFixed(2)} ${spec.unit}`;
}
// The generic progress OSD, with "zoom" as its kind (OsdModel maps that to
// the zoom-in icon). The bar is drawn over the USABLE range -- 1.00 × is an
// empty bar rather than a fifth-full one, because 1.00 × is off.
function showZoomOsd(factor: real): void {
const spec = PreferenceSchema.spec("magnifierFactor");
const span = Math.round((spec.max - spec.min) * 100);
OsdState.progress("zoom", Math.round((factor - spec.min) * 100), span, root.zoomLabel(factor));
}
// ── The screen reader ───────────────────────────────────────────────────
//
// Orca, reported as what it is: a process that is either running or not.
//
// There is no desktop switch to offer instead. GNOME's
// org.gnome.desktop.a11y.applications screen-reader-enabled key is acted on
// by gnome-settings-daemon, which does not run in this session, so writing
// it would store a preference that starts nothing -- exactly the kind of
// dead switch this page exists to not ship.
//
// `pgrep -x orca` is correct even though orca is a Python script: it calls
// set_process_name("orca") at startup (/usr/sbin/orca), which goes through
// setproctitle or prctl(PR_SET_NAME) and renames comm, which is what pgrep
// matches without -f. Verified against the installed orca, not assumed.
property bool orcaRunning: false
// Whether the accessibility bus is actually up, so the page's readiness
// line is probed rather than claimed. org.a11y.Bus is what Orca and every
// AT-SPI client connect through; if it is missing, Orca will start and read
// nothing, and saying so beforehand is cheaper than the confusion.
property bool accessibilityBusRunning: false
// Probed on demand -- when the Accessibility page opens, and after Start or
// Stop -- rather than on a timer. Nothing on this desktop needs to know
// about Orca while the page is closed, and a poll that runs all session to
// answer a question nobody asked is a cost with no reader.
function refreshScreenReader(): void {
if (!screenReaderQuery.running)
screenReaderQuery.running = true;
}
Process {
id: screenReaderQuery
command: [
"bash", "-lc",
"printf '%s %s' "
+ "$(pgrep -x orca >/dev/null 2>&1 && printf true || printf false) "
+ "$(gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus "
+ "--method org.a11y.Bus.GetAddress >/dev/null 2>&1 && printf true || printf false)"
]
stdout: StdioCollector {
onStreamFinished: {
const fields = this.text.trim().split(/\s+/);
root.orcaRunning = fields[0] === "true";
root.accessibilityBusRunning = fields[1] === "true";
}
}
}
// Start and stop are separate functions rather than one toggle, because the
// button they back says which one it is doing. A toggle reading stale state
// would start a second Orca or stop nothing.
//
// Detached on purpose, and not through this file's Process queue: a queued
// Process is a CHILD of the shell, so every hot reload of Quickshell would
// take the screen reader down with it. Somebody who needs Orca to read the
// screen must not lose it because a panel was edited.
function startOrca(): void {
root.lastError = "";
Quickshell.execDetached(["orca"]);
root.settleScreenReader();
}
function stopOrca(): void {
root.lastError = "";
// -x for the same reason the probe uses it, and by name rather than by
// the PID the probe saw: the probe is a snapshot from some moments ago,
// and killing a remembered PID is how you end up killing whatever
// reused it.
Quickshell.execDetached(["pkill", "-x", "orca"]);
root.settleScreenReader();
}
// Orca takes a couple of seconds to come up and a moment to go down, so one
// probe fired immediately after the action would report the state that just
// changed. Three probes, then stop: bounded by construction, so a failure
// to start cannot leave a poll running for the rest of the session.
property int settleTicks: 0
function settleScreenReader(): void {
root.settleTicks = 0;
screenReaderSettle.restart();
}
Timer {
id: screenReaderSettle
interval: 900
repeat: true
onTriggered: {
root.settleTicks += 1;
root.refreshScreenReader();
if (root.settleTicks >= 3)
screenReaderSettle.stop();
}
}
} }
+47 -11
View File
@@ -412,35 +412,71 @@ Singleton {
// dozen overlapping bells, which is a noise rather than a notification. // dozen overlapping bells, which is a noise rather than a notification.
property real lastBellAt: 0 property real lastBellAt: 0
function playBell(notification: var): void { // Emitted for every notification that is bell-eligible, whether or not a
if (!SoundFeedback.eventSounds) // bell is actually audible. modules/notifications/VisualBell.qml listens.
return; signal bellEligible(var notification)
// Would this notification ring the bell, setting aside whether sound is
// switched on at all?
//
// THE PINNED RULE, because it is the whole point of visual alerts: the
// flash follows this predicate, and the bell follows this predicate AND
// SoundFeedback.eventSounds. Every gate below is shared -- an application
// you silenced stays silent both ways, a low-urgency notification stays
// quiet both ways, and an application that says it played its own sound is
// taken at its word both ways -- but the event-sounds switch is NOT.
//
// Gating the flash on event sounds would make Visual Alerts do nothing for
// exactly the person it exists for: somebody who cannot hear the bell has
// no reason to have event sounds on, and would turn on a switch that stays
// dark. The flash is not a picture of the bell; it is the same alert in the
// sense the person can receive.
//
// Sound RESOLUTION is deliberately not part of this. playBell gives up when
// it cannot find a file to play, which is a fact about the sound theme on
// disk; losing the flash because a theme is missing an ogg would be absurd.
function bellWouldRing(notification: var): bool {
// The per-application sound switch. Narrower than turning the // The per-application sound switch. Narrower than turning the
// application off: its notifications still arrive and still show, they // application off: its notifications still arrive and still show, they
// just stop making noise. // just stop making noise.
if (!root.appRule(root.notificationAppId(notification)).sound) if (!root.appRule(root.notificationAppId(notification)).sound)
return; return false;
// Low urgency is the "you did not need to know this" tier -- battery // Low urgency is the "you did not need to know this" tier -- battery
// reaching full, a sync completing. It stays silent by design, and it // reaching full, a sync completing. It stays silent by design, and it
// is the effective urgency, so "treat as low" is a way to keep an // is the effective urgency, so "treat as low" is a way to keep an
// application audible in principle but quiet in practice. // application audible in principle but quiet in practice.
if (root.effectiveUrgency(notification) === NotificationUrgency.Low) if (root.effectiveUrgency(notification) === NotificationUrgency.Low)
return; return false;
// The freedesktop sound hints. This is the fix for the double chime: // The freedesktop sound hint. This is the fix for the double chime: an
// an application that plays its own sound sets suppress-sound so the // application that plays its own sound sets suppress-sound so the
// notification server stays quiet, and Panama ignoring it meant one // notification server stays quiet, and Panama ignoring it meant one
// notification made two noises a beat apart. // notification made two noises a beat apart.
//
// The other two say what to play instead of the theme bell --
// sound-file is an absolute path the application supplies, sound-name
// is a theme sound resolved through the same chain the bell uses.
const hints = notification.hints ?? {}; const hints = notification.hints ?? {};
if (hints["suppress-sound"] === true) if (hints["suppress-sound"] === true)
return false;
return true;
}
function playBell(notification: var): void {
if (!root.bellWouldRing(notification))
return; return;
// Announced BEFORE the event-sounds gate, on purpose. See the rule
// pinned above bellWouldRing.
root.bellEligible(notification);
if (!SoundFeedback.eventSounds)
return;
// The remaining two sound hints say what to play instead of the theme
// bell -- sound-file is an absolute path the application supplies,
// sound-name is a theme sound resolved through the same chain the bell
// uses. Neither can make a notification ineligible, so neither is part
// of the predicate above.
const hints = notification.hints ?? {};
const soundFile = String(hints["sound-file"] ?? ""); const soundFile = String(hints["sound-file"] ?? "");
const soundName = String(hints["sound-name"] ?? ""); const soundName = String(hints["sound-name"] ?? "");
const candidates = soundFile.startsWith("/") const candidates = soundFile.startsWith("/")
@@ -249,6 +249,21 @@ Singleton {
{ label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" }, { label: "Rebind a shortcut", detail: "Change the keys an action answers to, or put them back", page: "shortcuts" },
{ label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" }, { label: "Pointer test area", detail: "Scribble and scroll to feel a pointer change before keeping it", page: "mouse" },
{ label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" }, { label: "Connected input devices", detail: "The keyboards, mice, and touchpad this machine can see", page: "mouse" },
// Accessibility. The schema covers the switches by their own labels, so
// these are the things people arrive with that the schema does not say:
// the verb rather than the noun ("zoom in", not "Magnifier"), the
// application's name rather than its category ("Orca", not "Screen
// reader"), and — the one worth having most — sticky keys, which this
// desktop does not offer at all. A search that found nothing there
// would read as the desktop having no opinion, when in fact the page
// carries a paragraph explaining exactly why the switch is absent.
{ label: "Magnifier zoom", detail: "Magnify the screen around the pointer, drawn by the compositor itself", page: "accessibility" },
{ label: "Zoom in and out", detail: "Step the magnifier from anywhere with a shortcut; the OSD shows the level", page: "accessibility" },
{ label: "Reduce motion", detail: "Still the windows, the workspaces, and the shell's own bar, dock and panels", page: "accessibility" },
{ label: "Visual alerts", detail: "Flash the screen edges once when a notification arrives, instead of relying on the bell", page: "accessibility" },
{ label: "Screen reader", detail: "Start Orca and see whether the accessibility bus is up", page: "accessibility" },
{ label: "Orca", detail: "The screen reader: whether it is running, and starting or stopping it", page: "accessibility" },
{ label: "Sticky keys", detail: "Why sticky, slow and bounce keys are not offered in this session", page: "accessibility" },
{ label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" }, { label: "Dictation", detail: "Speech to text with Super+D, typed where the cursor is", page: "dictation" },
{ label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" }, { label: "Speech to text", detail: "Set up the local speech server and model", page: "dictation" },
{ label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" }, { label: "System Health", detail: "Check Panama services, integrations, tools, and recovery actions", page: "services" },
+41
View File
@@ -89,6 +89,15 @@ ShellRoot {
Osd {} Osd {}
} }
// The visual bell, on every screen at once -- someone watching the other
// monitor is exactly who this is for. Each one stays unmapped until a
// bell-eligible notification arrives, and does nothing at all while the
// Visual alerts setting is off.
Variants {
model: Quickshell.screens
VisualBell {}
}
DisplayIdentify {} DisplayIdentify {}
// ── Single-instance overlays ──────────────────────────────────────────── // ── Single-instance overlays ────────────────────────────────────────────
@@ -331,6 +340,38 @@ ShellRoot {
} }
} }
// The magnifier keys: SUPER+ALT+= steps in, SUPER+ALT+- steps out,
// SUPER+ALT+0 goes back to 1.00 ×.
//
// They come through the shell rather than setting cursor:zoom_factor on the
// compositor directly, so the stored preference, the Magnifier slider and
// what is actually on screen are always the same number -- and so there is
// an OSD saying what the magnification now is, which matters when the thing
// you are looking at is somewhere else entirely.
//
// A dead shell means dead zoom keys. That is honest: with the shell down
// there is no bar, no dock and no OSD either.
IpcHandler {
target: "accessibility"
function zoom(direction: string): string { return Accessibility.stepZoom(direction); }
// Re-probe the screen reader and the accessibility bus. The settings
// page calls Accessibility.refreshScreenReader() directly when it
// opens; this is the same probe from outside, so `status` below can be
// asked for something other than "not looked yet".
function refresh(): void { Accessibility.refreshScreenReader(); }
function status(): string {
return JSON.stringify({
magnifierFactor: Accessibility.magnifierFactor,
visualAlerts: Settings.visualAlerts,
orcaRunning: Accessibility.orcaRunning,
accessibilityBusRunning: Accessibility.accessibilityBusRunning
});
}
}
IpcHandler { IpcHandler {
target: "activity" target: "activity"
@@ -5,6 +5,6 @@
# @vicinae.mode silent # @vicinae.mode silent
# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg
# @vicinae.description Open Accessibility in Settings. # @vicinae.description Open Accessibility in Settings.
# @vicinae.keywords ["settings", "magnifier", "magnifier follows in steps", "high contrast", "dim inactive windows", "dim amount", "pointer size", "text size"] # @vicinae.keywords ["settings", "magnifier", "magnifier follows in steps", "high contrast", "dim inactive windows", "dim amount", "flash the screen for notifications", "pointer size", "text size", "magnifier zoom", "zoom in and out", "reduce motion", "visual alerts"]
exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accessibility exec "$HOME/.config/quickshell/scripts/panama-action" settings-page accessibility
+3 -2
View File
@@ -4,7 +4,7 @@
Do not edit this file. Run `quickshell/scripts/panama-settings-docs` Do not edit this file. Run `quickshell/scripts/panama-settings-docs`
after changing the schema; a contract fails when this copy is stale. after changing the schema; a contract fails when this copy is stale.
173 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back. 174 settings across 36 groups. 77 of them are applied to the compositor and confirmed by reading the value back.
## accessibility ## accessibility
@@ -12,11 +12,12 @@ Found on **Accessibility**.
| Setting | Default | What it does | | Setting | Default | What it does |
|---|---|---| |---|---|---|
| **Magnifier**<br>`magnifierFactor` `cursor:zoom_factor` | 1.0 | Magnifies the screen around the pointer. 1.0 is off. Range 1.05.0. | | **Magnifier**<br>`magnifierFactor` `cursor:zoom_factor` | 1.0 × | Magnifies the screen around the pointer. 1.00 × is off. Range 1.05.0. |
| **Magnifier follows in steps**<br>`magnifierRigid` `cursor:zoom_rigid` | false | Moves the magnified view in increments rather than gliding with the pointer | | **Magnifier follows in steps**<br>`magnifierRigid` `cursor:zoom_rigid` | false | Moves the magnified view in increments rather than gliding with the pointer |
| **High contrast**<br>`highContrast` | false | Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here. | | **High contrast**<br>`highContrast` | false | Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here. |
| **Dim inactive windows**<br>`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable | | **Dim inactive windows**<br>`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable |
| **Dim amount**<br>`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.050.9. | | **Dim amount**<br>`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.050.9. |
| **Flash the screen for notifications**<br>`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell |
| **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 1664. | | **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 1664. |
| **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.752.0. | | **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.752.0. |
@@ -1470,3 +1470,205 @@ benefits from.
`search-routing-contract`, `schema-hypr-shape-contract`, `search-routing-contract`, `schema-hypr-shape-contract`,
`preference-schema-contract`, `settings-docs-contract`, `preference-schema-contract`, `settings-docs-contract`,
`readme-contract`), then the harness ones last. `readme-contract`), then the harness ones last.
## Phase 14 (Accessibility) — append below
Spec: `2026-08-24-accessibility-redesign.md`. The Accessibility page stopped
being six cards of controls sorted by mechanism plus one card that apologised,
and became five sorted by what is being accommodated: Vision, Motion, Hearing,
Keyboard & pointer, Screen reader. Behind them, the four things an
accessibility page exists for and this one did not have: the magnifier gained
keybinds routed through the shell, Reduce motion became true of the shell's own
bar, dock, panels and OSD rather than only of the compositor, a visual
alternative to the notification bell arrived, and Orca became a process that is
reported as running or not instead of a button that launched it and forgot.
Invisible and largest: the nine shared row primitives gained screen-reader
names, roles, Tab stops and key handling, which reaches all fourteen categories
at once.
Three agents edited the tree concurrently; everything below was reconciled
against the landed files rather than against the spec's pinned shapes.
### New contracts (1)
`quickshell/accessibility-contract`. The README count line moves **174 → 175**;
`setup/readme-contract` was run and passes ("175 contracts, as documented").
Entirely static, on purpose. Every failure it guards against is one a live run
would report as working: `hyprctl keyword` exits 0 on a Lua-configured Hyprland
while refusing the write, a gsettings `screen-reader-enabled` write succeeds and
starts nothing, and a row with no `Accessible.name` looks and behaves exactly
like one that has it. Nothing here starts a shell, applies a zoom, starts or
stops Orca, or writes a gsettings key.
What it pins, in the order the file argues them:
- **The zoom path commits through the preference and never touches `hyprctl`.**
Asserted over the PATH rather than over `stepZoom`'s own text: the slice is
`stepZoom` plus every function in `Accessibility.qml` it hands work to
(`showZoomOsd`, `zoomLabel`), collected transitively, so splitting the OSD
call into a helper moves the assertions with it rather than out from under
them. Within that path: no `hyprctl`, no `keyword`, a
`SystemSettings.commitPreference` call, an `OsdState` post, and clamp bounds
that either read `PreferenceSchema.spec` or match the schema's own 1.05.0.
The `accessibility` IPC handler in `shell.qml` is checked the same way.
- **The three binds, matched by target and verb rather than by chord.**
`SUPER+=` was already "Reset split", so which free chords the zoom keys take
was a decision made against `keybinds.lua` rather than against the mock — a
contract naming `SUPER+=` would have been wrong on the day it was written.
A separate check fails on any chord bound twice, which is the actual hazard:
Hyprland takes the last bind and the earlier action stops working silently.
- **The visual bell is one-shot.** No `loops: Animation.Infinite`, no `loops:
-1`, no Timer with `repeat: true` anywhere behind it, and it reads
`visualAlerts`.
- **The flash follows bell eligibility EXCEPT the event-sounds gate.** The
signal name is read out of `VisualBell.qml`'s `Connections { target: Notifs }`
rather than guessed, which also pins that the two are really wired. Two
structural checks then run over `Notifs.qml`: no `SoundFeedback.eventSounds`
early return may stand between the enclosing function's first line and the
emit, and the emit must sit on a path that DOES carry the shared gates
(`appRule(...).sound`, low urgency, `suppress-sound`) — otherwise the flash
would not be the bell seen, but a second louder notifier ignoring every
per-application rule somebody set. A third check fails if the file stops
explaining the exception, since an undocumented exception is one the next
reader helpfully removes.
- **Orca is a process, never a gsettings key.** `screen-reader-enabled` is
grepped for repo-wide across the shell's QML and JS, comment-stripped first.
Plus: `orcaRunning` exists, something `pgrep`s, and the probe is not on a
repeating Timer — a poll that runs all session to answer a question nobody
asked is a cost with no reader.
- **The nine primitives.** `Accessible.name`, `Accessible.role` and
`activeFocusOnTab` in each of `SliderRow`, `ToggleRow`, `SwitchRow`,
`ActionRow`, `ChoiceRow`, `SegmentRow`, `SettingRow`, `OptionPickerRow`,
`PickerRow`, resolved through the QML inheritance chain (a name inherited
from `SettingRow` is a name the row really has) — with one exception that is
NOT inheritable: a file drawing its own `MouseArea`/`SettingsToggle`/
`SettingsButton`/`ValueSlider` must declare its own `activeFocusOnTab`,
because `SettingRow` is a Tab stop only when `activatable`, which a
`ToggleRow` is not. Then: Space/Enter activation on the four rows that
activate, Left/Right on the two that hold a range, a focus indicator gated on
`activeFocus`, and — the visual-at-rest promise — no focus border painted
unconditionally, since these rows are used by every page.
- **The page, per the approved mock.** The five card titles, the five retired
ones absent, `dimStrength` gated on `dimInactive`, the mono-audio row present
and marked not-yet with somewhere to go meanwhile, the Keyboard jump going
through `openSettings()` rather than assigning `settingsPage` by hand, and no
`Process` on the page or its own components.
- **The honesty box is a statement, not an alarm.** It must name Hyprland (not
"Wayland"), say the absence was probed rather than assumed, and explain why
the GNOME switches are inert here — and it must NOT be painted in
`Theme.danger`/`Theme.warn` or use the words Warning, Error, Unsupported,
unfortunately, sorry, Broken. An accessibility page is the last page that can
afford to open with an alarm.
- **The dead zero label, swept across every settings page rather than only this
one.** `SliderRow.display()` substitutes `zeroLabel` at exactly 0, so a row
setting one for a setting whose schema minimum is above 0 is copy that can
never appear — which is what `zeroLabel: "Off"` was doing under a magnifier
whose minimum IS 1.0. Any page reintroducing that class of bug now fails.
- **Reduce motion is true of the shell**: `Theme.motionEnabled` reads
`Settings.animationsEnabled`, and all four duration tokens collapse to 0.
- **The cross-goal collision, which is silent.** `VisualBell` must not time its
flash with a `Theme.dur*` token: those now collapse to 0 under Reduce motion,
so a flash timed with `Theme.durFast` would be instantaneous and therefore
invisible — switching on Reduce motion would silently switch OFF Visual
alerts, for somebody quite likely to want both. A landed and pinned.
### Reconciled
- **`quickshell/notification-app-rules-contract` — RUN, PASS after a fix.** It
broke on the visual-alerts refactor: the bell's shared gates moved out of
`playBell` into the new `bellWouldRing` predicate, and the contract read only
`playBell`'s literal body, so it reported that the per-application sound
switch and the effective-urgency check had been dropped when both had merely
moved one call up. Now asserted over the bell's DECISION PATH (`playBell` +
`bellWouldRing`), plus a new assertion that `playBell` actually consults the
predicate — without which every gate would sit in a function nothing runs,
which reads exactly like a passing contract.
- **`quickshell/osd-model-contract` — RUN, PASS.** Gained the magnifier's OSD
kind: `iconFor('zoom', 0.4)` is `zoom-in-symbolic` and `iconFor('zoom', 0)` is
`zoom-original-symbolic`, since an empty bar is 1.00 ×, which is off rather
than barely magnified. Without a mapping the kind falls through to its own
name and the OSD draws the generic fallback glyph — for the one shortcut
whose whole job is telling somebody who cannot read the screen what the
magnification now is.
- **No contract pinned the Theme duration literals.** Checked by grep across
`tests/` for `durFast`/`durNormal`/`durSlow`/`durDockReveal` and
`motionEnabled`: zero hits before the change, so gating them broke nothing.
`accessibility-contract` now pins the gating itself, which is the first time
those tokens have been pinned anywhere.
- **`quickshell/settings-ownership-contract` — RUN, PASS unchanged.** Its
duplicate table expects exactly `animationsEnabled`, `cursorInactiveTimeout`,
`cursorSize` and `inactiveOpacity` to appear on two pages, and the rebuilt
page keeps all four, so the mirror set did not move.
### Seven search entries added
Magnifier zoom, Zoom in and out, Reduce motion, Visual alerts, Screen reader,
Orca, Sticky keys — all routing to `accessibility`. The schema already covers
the switches by their own labels, so these are only the words people arrive
with that no label uses: the verb rather than the noun ("zoom in", not
"Magnifier"), the application's name rather than its category ("Orca", not
"Screen reader"), and the schema's own wording gap ("Reduce motion", where the
label reads "Animations"; "Visual alerts", where it reads "Flash the screen for
notifications").
**High contrast was deliberately NOT added**, and the contract fails if it is:
it is already a schema label, and the index covers every schema label, so a
second copy would list the same setting twice in one result. The contract also
fails if any of these entries routes anywhere but `accessibility`.
### Schema docs and launcher commands regenerated
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` both
run without `--check` and their output committed. `docs/settings.md` goes 173 →
**174 settings across 36 groups**: `visualAlerts` renders in the accessibility
group, and `magnifierFactor` picks up its new `×` unit in both the default
column ("1.0 ×") and the detail ("1.00 × is off"). `settings-accessibility`'s
Vicinae keywords gained "flash the screen for notifications", "magnifier zoom",
"zoom in and out", "reduce motion" and "visual alerts".
`quickshell/settings-docs-contract` — RUN, PASS (174 settings documented);
`quickshell/panama-commands-contract` — RUN, PASS (77 commands; the
settings generator writes 38 of them).
### Deferred, and why
- **`quickshell/settings-pages-contract`, `settings-write-sweep-contract`,
`settings-search-contract`, `settings-buttons-contract` (live half),
`settings-preferences-contract`, `settings-commit-reset-contract`,
`settings-system-contract`, `settings-hyprland-write-contract` — NOT RUN**:
they daemonize a Quickshell harness or drive the live settings window. The
Accessibility page was rebuilt this phase, `SettingsNote.qml` is new, and all
nine shared row primitives changed, so this set is the end-of-redesign sweep
and matters more here than it did in any previous phase — a broken
`SliderRow` takes every settings page down, and only these would see it.
`qmldir-registration-contract` (static) was run and passes, so
`SettingsNote` at least resolves.
- **The whole live half of the zoom keybinds.** Nothing here presses a key. The
contract proves the binds exist, name the IPC verb, sit on unoccupied chords,
and that the path behind them commits through the preference; whether
Hyprland picks the new binds up needs a `hyprctl reload`, which this phase
deliberately did not run.
- **The flash has not been seen.** `VisualBell` is pinned structurally — one
animation, no loops, no repeating timer, gated on the preference, not timed
with a motion-gated token — but nobody has watched a notification arrive with
Visual alerts on. That is the one assertion here a screenshot would settle
and a grep cannot.
- **Orca has not been started or stopped.** The service is pinned as a process
probe with on-demand polling; whether `pgrep -x orca` matches the installed
Orca is A's verified claim in a comment (it calls `set_process_name("orca")`,
which renames `comm`), not something this contract re-checks.
- **The screen reader has not read the page.** `Accessible.name`, `role` and
`activeFocusOnTab` are present in all nine files and the key handlers are
there, but no AT-SPI client has walked the settings window to confirm the
names come out in a useful order. That needs Orca running against a live
shell.
- **`quickshell/sound-page-contract` fails, and it is not this work.** Verified
by stashing the whole working tree and running it against a clean checkout:
it fails identically ("DictationPage does not report whether the speech
server is installed"), so it was already red before this phase began.
- Run order for this phase: the static ones first (`accessibility-contract`,
`osd-model-contract`, `preference-schema-contract`,
`schema-hypr-shape-contract`, `settings-ownership-contract`,
`search-routing-contract`, `ipc-targets-contract`, `settings-jump-contract`,
`settings-docs-contract`, `readme-contract`, all eight of `tests/hypr/`),
then the sandboxed-harness ones, then the live settings window last.
@@ -0,0 +1,70 @@
# Accessibility redesign — everything that's real
Approved mock: `home-mocks/accessibility.html` (scratchpad, :8642). Spec wins over mock on
conflict. Tabless leaf stays.
## Goals
1. **Magnifier keybinds**: Super+= / Super+- / Super+0 step the zoom from anywhere, through the
shell so the stored preference stays truthful and the OSD shows the level. Mechanism: the
binds call `qs ipc call accessibility zoom in|out|reset`; a new IPC target routes to
`Accessibility.stepZoom()`, which computes (×1.25 steps, clamp to the schema's 1.05.0),
commits through the normal verified-preference path, and posts the OSD. No new script; a
dead shell means dead zoom keys, which is honest (everything else in the shell is dead too).
2. **Reduce motion becomes true**: `Theme.qml`'s `dur*` tokens gate on
`Settings.animationsEnabled` (0 when off) so the bar/dock/OSD/panels genuinely still.
3. **Visual alerts**: new schema key `visualAlerts` (bool, def false, group `accessibility`);
when on, a one-shot screen-edge flash (per-screen overlay, single animation per event,
never looping) fires wherever `Notifs.playBell` decides a notification sounds (including
when the audible bell is skipped for `sound: false` apps? No — the flash follows the same
eligibility as the bell EXCEPT the eventSounds gate: visual alerts are for people who can't
hear the bell, so the flash fires on bell-eligible notifications even when event sounds are
off. Pin that.)
4. **Honest Orca control**: running state (pgrep poll on page open + after actions),
Start/Stop; the a11y-bus readiness line. No gsettings screen-reader toggle (gsd-owned,
inert — refused).
5. **The primitives get accessible**: `SliderRow`, `ToggleRow`, `SwitchRow`, `ActionRow`,
`ChoiceRow`, `SegmentRow`, `SettingRow` (activatable), `OptionPickerRow`, `PickerRow`
gain `Accessible.role`/`Accessible.name` (from label; detail as description),
`activeFocusOnTab` on the interactive element, key handling (Space/Enter activates,
Left/Right steps sliders and segments), and a visible focus indicator (the titlebar
close-button border pattern). No API or visual-at-rest changes.
6. **Page rebuilt** per mock: Vision / Motion / Hearing / Keyboard & pointer / Screen reader;
`dimStrength` gated on `dimInactive`; magnifier "1× is off" honesty (schema gains
`unit: "×"`-style display via the page detail — fix the dead `zeroLabel`); the honesty box
for sticky/slow/bounce with the probed-not-assumed copy; mono-audio NOT YET row; "Open
Keyboard" jump.
7. Cleanups: `DESKTOP-PARITY.md`'s stale AccessX claim corrected; search entries for
magnifier/zoom/orca/screen reader/high contrast/reduce motion/visual alerts.
Non-goals: mono audio (deferred with the on-page honesty), sticky/slow/bounce/hover/mouse
keys (refused with receipts), gsettings `enable-animations`/`screen-reader-enabled` writes
(inert), shell text scaling.
## Ownership
- **A**: `config/PreferenceSchema.qml` (visualAlerts; magnifierFactor detail/unit fix),
`config/dot/hypr/keybinds.lua` (three zoom binds — CHECK for collisions with existing
Super+=/-/0 binds first; pick free chords and update the mock copy via B if needed),
`shell.qml` (accessibility IPC target), `services/Accessibility.qml` (stepZoom, orcaRunning
+ start/stop, poll discipline), `services/Notifs.qml` (flash trigger signal per goal 3),
NEW `modules/notifications/VisualBell.qml` (+ per-screen wiring in shell.qml),
`config/dot/hypr/DESKTOP-PARITY.md` (stale claim), `services/OsdState.qml`/`OsdModel.js`
only if the zoom OSD needs a new kind (coordinate with C on osd-model-contract).
- **B**: the nine row primitives (accessibility additions only — zero API/visual-at-rest
change), `modules/settings/AccessibilityPage.qml` rebuild (+ any new components + qmldir).
Reuse KeycapChord for the chord display.
- **C**: `services/SettingsSearch.qml`, NEW `tests/quickshell/accessibility-contract` (page
structure; zoom IPC path commits through the verified-preference path and never calls
hyprctl keyword; visual bell one-shot — no `loops: Animation.Infinite`, no Timer-driven
repeat; flash follows bell eligibility but not the eventSounds gate; primitives carry
Accessible.name + activeFocusOnTab — sweep all nine files; Orca control uses process state,
never gsettings), `osd-model-contract` (zoom kind if added), reconcile any contract pinning
Theme durations or the primitives' file contents, backlog Phase 14, README count
(174 → 175 expected), docs/commands regen at the end (schema changed).
Hard rules: no live mutations (no zoom application, no orca start/stop, no gsettings writes,
no hyprctl keyword/reload); hermetic stubs only; valid QML at every save (primitives are used
by EVERY page — a broken SliderRow takes the whole settings app down, so B edits them one at
a time with a reload-check between each). B programs against A's pinned APIs; A updates this
spec before changing them.
+889
View File
@@ -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'
@@ -194,7 +194,23 @@ for (const required of ["rule.urgency === \"low\"", "rule.urgency === \"critical
fail(`effectiveUrgency is missing ${required}`); fail(`effectiveUrgency is missing ${required}`);
} }
const bell = functionBody("playBell"); // The DECISION PATH of the bell, rather than the text of one function. Visual
// alerts split the shared gates out into `bellWouldRing`, so the screen flash
// can follow the same rules as the chime without following the event-sounds
// switch (the rule is pinned above that function). The gates below did not
// change; they moved one call up, and a contract reading only the body of
// playBell would have reported the per-application sound switch as dropped.
//
// NOTE: this whole block is a bun -e argument in single quotes. No apostrophes.
const playBell = functionBody("playBell");
const bell = playBell + functionBody("bellWouldRing");
// ...and the split only holds while playBell actually consults the predicate.
// A playBell that stopped calling it would leave every gate below in a
// function nothing runs, which reads exactly like a passing contract.
if (!/bellWouldRing\(notification\)/.test(playBell))
fail("playBell no longer consults bellWouldRing, so the per-application, urgency and suppress-sound gates sit in a function the bell never calls");
if (!bell.includes("root.effectiveUrgency(notification) === NotificationUrgency.Low")) if (!bell.includes("root.effectiveUrgency(notification) === NotificationUrgency.Low"))
fail("the bell reads the claimed urgency rather than the effective one, so treat-as-low would still chime"); fail("the bell reads the claimed urgency rather than the effective one, so treat-as-low would still chime");
// The per-application sound switch is narrower than the enabled switch: the // The per-application sound switch is narrower than the enabled switch: the
+11
View File
@@ -16,6 +16,17 @@ assert.equal(model.iconFor('volume', 0.9), 'audio-volume-high-symbolic')
assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic') assert.equal(model.iconFor('microphone-muted', 0.7), 'microphone-sensitivity-muted-symbolic')
assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic') assert.equal(model.iconFor('brightness', 0.4), 'display-brightness-symbolic')
// The magnifier's OSD, posted by Accessibility.stepZoom when a zoom keybind is
// pressed. An unmapped kind falls through iconFor to the kind's own name, and
// "zoom" is not an icon -- the OSD would draw the generic fallback glyph for
// the one shortcut whose whole job is telling somebody who cannot read the
// screen what the magnification now is.
// An empty bar is 1.00 ×, which is off rather than "barely magnified", so it
// gets the reset icon: the zoom-in glyph beside an empty bar would read as a
// magnifier that had stopped working.
assert.equal(model.iconFor('zoom', 0.4), 'zoom-in-symbolic')
assert.equal(model.iconFor('zoom', 0), 'zoom-original-symbolic')
assert.deepEqual( assert.deepEqual(
model.progressState('volume', 140, 100, '', 900), model.progressState('volume', 140, 100, '', 900),
{ {
+7 -3
View File
@@ -242,9 +242,13 @@ rg -Fq 'label: "Set up dictation"' "$dictation_page" \
|| fail 'DictationPage has no setup action, so the one-time install cannot be started from Settings' || fail 'DictationPage has no setup action, so the one-time install cannot be started from Settings'
rg -Fq 'onTriggered: Dictation.setup()' "$dictation_page" \ rg -Fq 'onTriggered: Dictation.setup()' "$dictation_page" \
|| fail 'the dictation setup action does not call the helper that installs both the speech server and the model' || fail 'the dictation setup action does not call the helper that installs both the speech server and the model'
rg -Fq 'label: "Speech server"' "$dictation_page" \ # The Input-phase rebuild replaced the two literal TextRows with a status hero
|| fail 'DictationPage does not report whether the speech server is installed' # and setup steps, so the pin follows the state properties rather than the old
rg -Fq 'label: "Speech model"' "$dictation_page" \ # row labels -- what matters is that the page still tells the truth about both
# halves of the install, not what furniture carries it.
rg -Fq 'Dictation.serverReady' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech server is answering'
rg -Fq 'Dictation.modelInstalled' "$dictation_page" \
|| fail 'DictationPage does not report whether the speech model is installed' || fail 'DictationPage does not report whether the speech model is installed'
rg -Fq 'Dictation.typingAvailable' "$dictation_page" \ rg -Fq 'Dictation.typingAvailable' "$dictation_page" \
|| fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard' || fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard'