diff --git a/README.md b/README.md index e49009d..3e58e74 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work ## 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 panama test # everything diff --git a/config/dot/hypr/DESKTOP-PARITY.md b/config/dot/hypr/DESKTOP-PARITY.md index 4d9dea9..9d15382 100644 --- a/config/dot/hypr/DESKTOP-PARITY.md +++ b/config/dot/hypr/DESKTOP-PARITY.md @@ -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 a change event Quickshell already consumes, so this needs either polling or 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 - with no Wayland equivalent; GNOME, macOS and Windows all ship these. +4. Sticky keys, slow keys and bounce keys. Wayland has no protocol for 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. KDE Connect, the printer UI, Tesseract, and ZBar are installed and remain diff --git a/config/dot/hypr/keybinds.lua b/config/dot/hypr/keybinds.lua index c59aec1..6dde0dc 100644 --- a/config/dot/hypr/keybinds.lua +++ b/config/dot/hypr/keybinds.lua @@ -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. 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 +-- = and -, 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 ─────────────────────────────────────────────────────── category("Windows") bind(mod .. " + Q", hl.dsp.window.close(), { description = "Close window" }) diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index cbc57c5..a253a49 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1022,9 +1022,14 @@ Singleton { // this app refuses to ship. { key: "magnifierFactor", type: "real", def: 1.0, min: 1.0, max: 5.0, step: 0.1, - group: "accessibility", + unit: "×", group: "accessibility", 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" } }, { @@ -1054,6 +1059,16 @@ Singleton { detail: "How much darker unfocused windows are", 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 ────────────────────────────────────────────────────────── // What Panama does while a game runs. gamemode tells us when that diff --git a/config/dot/quickshell/config/Settings.qml b/config/dot/quickshell/config/Settings.qml index 5134c11..6556296 100644 --- a/config/dot/quickshell/config/Settings.qml +++ b/config/dot/quickshell/config/Settings.qml @@ -86,6 +86,18 @@ Singleton { // services/Notifs.qml. Off means Do Not Disturb is absolute. 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 ─────────────────────────────────────────────────────────────── // 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 diff --git a/config/dot/quickshell/config/Theme.qml b/config/dot/quickshell/config/Theme.qml index 65f6f73..f9c548b 100644 --- a/config/dot/quickshell/config/Theme.qml +++ b/config/dot/quickshell/config/Theme.qml @@ -204,14 +204,21 @@ Singleton { // ── Motion ────────────────────────────────────────────────────────────── // Event-driven only. Nothing in this shell animates while idle — no pulse, // no shimmer, no spinners. These durations are used for open/close/hover. - readonly property int durFast: 120 - readonly property int durNormal: 200 - readonly property int durSlow: 320 + // + // All of them collapse to zero when Reduce motion is on: the Accessibility + // 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 // movement, so it gets its own (much shorter) duration. Anything slower // 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. readonly property list easeStandard: [0.05, 0.9, 0.1, 1.0] diff --git a/config/dot/quickshell/modules/notifications/VisualBell.qml b/config/dot/quickshell/modules/notifications/VisualBell.qml new file mode 100644 index 0000000..89ad180 --- /dev/null +++ b/config/dot/quickshell/modules/notifications/VisualBell.qml @@ -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 } + } +} diff --git a/config/dot/quickshell/modules/osd/OsdModel.js b/config/dot/quickshell/modules/osd/OsdModel.js index 28d84d8..052b0cf 100644 --- a/config/dot/quickshell/modules/osd/OsdModel.js +++ b/config/dot/quickshell/modules/osd/OsdModel.js @@ -26,6 +26,11 @@ function iconFor(kind, ratio) { return "audio-input-microphone-symbolic"; if (name === "brightness") 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") return "media-playback-start-symbolic"; if (name === "media-pause" || name === "media-paused") diff --git a/config/dot/quickshell/modules/settings/AccessibilityPage.qml b/config/dot/quickshell/modules/settings/AccessibilityPage.qml index c115f5d..d5dd962 100644 --- a/config/dot/quickshell/modules/settings/AccessibilityPage.qml +++ b/config/dot/quickshell/modules/settings/AccessibilityPage.qml @@ -1,5 +1,15 @@ // 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 // no configuration system -- the compositor, GTK applications, and the shell. // Panama's store is the source of truth and services/Accessibility.qml pushes @@ -13,75 +23,205 @@ SettingsPage { id: root 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 { - title: "Pointer" - subtitle: "Applied to the compositor and to applications at the same time." + // The zoom chords come from the compositor's live keymap, matched on the + // descriptions hypr/keybinds.lua gives them, so rebinding a zoom key + // 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" } - SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false } + readonly property string zoomInChord: root.chordFor("zoom in", "Super + Alt + =") + 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 { - title: "Text" - subtitle: "Scales text in applications. The shell's own panels are drawn at their design size, so they are unaffected." + title: "Vision" + 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" } - // Reaches GTK4 applications through the desktop portal, which - // republishes it as org.freedesktop.appearance contrast. No - // high-contrast theme is involved, and none is installed here -- older - // GTK3 applications will not change. + SliderRow { setting: "cursorSize" } ToggleRow { setting: "highContrast"; divider: false } } SettingsCard { 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" } - 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 { - title: "Keyboard accessibility" - 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." + title: "Hearing" + 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 { - label: "Screen reader" - detail: "Orca reads the screen aloud and works over the accessibility bus, which does run here" - action: "Start Orca" + label: "Key repeat" + detail: "Delay and speed are on the Keyboard page, with the rest of the keymap" + 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 - onTriggered: SystemSettings.openApplication("orca") + + SoundBadge { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Built in" + tone: Theme.ok + } } } diff --git a/config/dot/quickshell/modules/settings/ActionRow.qml b/config/dot/quickshell/modules/settings/ActionRow.qml index 98cf866..843982e 100644 --- a/config/dot/quickshell/modules/settings/ActionRow.qml +++ b/config/dot/quickshell/modules/settings/ActionRow.qml @@ -23,6 +23,11 @@ SettingRow { controlWidth: Math.max(110, button.implicitWidth + 8) + function press(): void { + if (root.enabled) + root.triggered(); + } + SettingsButton { id: button anchors.right: parent.right @@ -30,5 +35,33 @@ SettingRow { text: root.action enabled: root.enabled 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 + } } } diff --git a/config/dot/quickshell/modules/settings/ChoiceRow.qml b/config/dot/quickshell/modules/settings/ChoiceRow.qml index f54786c..9f0c670 100644 --- a/config/dot/quickshell/modules/settings/ChoiceRow.qml +++ b/config/dot/quickshell/modules/settings/ChoiceRow.qml @@ -24,7 +24,28 @@ SettingRow { detail: root.spec ? root.spec.detail : "" 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 { + id: group + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter implicitWidth: segments.implicitWidth + 4 @@ -33,6 +54,35 @@ SettingRow { color: Theme.alpha(Theme.fg, 0.07) 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 { id: segments anchors.centerIn: parent @@ -54,6 +104,13 @@ SettingRow { border.width: 0 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 // in a row: blue leads into orchid, never orchid alone. Rectangle { diff --git a/config/dot/quickshell/modules/settings/OptionPickerRow.qml b/config/dot/quickshell/modules/settings/OptionPickerRow.qml index d4d5d5f..950c4e5 100644 --- a/config/dot/quickshell/modules/settings/OptionPickerRow.qml +++ b/config/dot/quickshell/modules/settings/OptionPickerRow.qml @@ -68,6 +68,18 @@ PickerRow { controlWidth: 90 divider: index < root.options.length - 1 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: { root.picked(modelData.value); root.collapse(); diff --git a/config/dot/quickshell/modules/settings/PickerRow.qml b/config/dot/quickshell/modules/settings/PickerRow.qml index f02029e..f8b175b 100644 --- a/config/dot/quickshell/modules/settings/PickerRow.qml +++ b/config/dot/quickshell/modules/settings/PickerRow.qml @@ -50,6 +50,17 @@ Column { controlWidth: 210 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 { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index a8a7b80..a488dd7 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -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 | | `ActionRow` | A button: opens a GNOME panel, runs a one-shot | | `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. Before Stage 3 more than half of all rows were static text standing in for diff --git a/config/dot/quickshell/modules/settings/SegmentRow.qml b/config/dot/quickshell/modules/settings/SegmentRow.qml index cafc2b9..ae2207c 100644 --- a/config/dot/quickshell/modules/settings/SegmentRow.qml +++ b/config/dot/quickshell/modules/settings/SegmentRow.qml @@ -18,12 +18,61 @@ SettingRow { 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 { + id: strip + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 6 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 { model: root.options @@ -45,6 +94,12 @@ SettingRow { ? Theme.alpha(Theme.accent, 0.5) : 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 { id: segmentLabel anchors.centerIn: parent diff --git a/config/dot/quickshell/modules/settings/SettingRow.qml b/config/dot/quickshell/modules/settings/SettingRow.qml index 4e072c6..fcf8ac1 100644 --- a/config/dot/quickshell/modules/settings/SettingRow.qml +++ b/config/dot/quickshell/modules/settings/SettingRow.qml @@ -18,9 +18,33 @@ Item { property bool activatable: false 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 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 { id: iconLabel anchors.left: parent.left @@ -104,6 +128,20 @@ Item { 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 { id: rowHover enabled: root.activatable diff --git a/config/dot/quickshell/modules/settings/SettingsNote.qml b/config/dot/quickshell/modules/settings/SettingsNote.qml new file mode 100644 index 0000000..0928e5d --- /dev/null +++ b/config/dot/quickshell/modules/settings/SettingsNote.qml @@ -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 + } + } +} diff --git a/config/dot/quickshell/modules/settings/SliderRow.qml b/config/dot/quickshell/modules/settings/SliderRow.qml index 7f2d64a..e2b4a0a 100644 --- a/config/dot/quickshell/modules/settings/SliderRow.qml +++ b/config/dot/quickshell/modules/settings/SliderRow.qml @@ -81,6 +81,22 @@ Item { 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 { if (value === 0 && root.zeroLabel !== "") return root.zeroLabel; @@ -145,6 +161,35 @@ Item { root.pending = root.quantise(ratio); 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 { diff --git a/config/dot/quickshell/modules/settings/SwitchRow.qml b/config/dot/quickshell/modules/settings/SwitchRow.qml index a74bd86..52c4abf 100644 --- a/config/dot/quickshell/modules/settings/SwitchRow.qml +++ b/config/dot/quickshell/modules/settings/SwitchRow.qml @@ -17,7 +17,16 @@ SettingRow { 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 { + id: pill + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter width: 44 @@ -26,6 +35,31 @@ SettingRow { color: root.checked ? Theme.accent : Theme.alpha(Theme.fg, 0.14) 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 { x: root.checked ? parent.width - width - 3 : 3 anchors.verticalCenter: parent.verticalCenter diff --git a/config/dot/quickshell/modules/settings/ToggleRow.qml b/config/dot/quickshell/modules/settings/ToggleRow.qml index bb5814d..61379fc 100644 --- a/config/dot/quickshell/modules/settings/ToggleRow.qml +++ b/config/dot/quickshell/modules/settings/ToggleRow.qml @@ -24,10 +24,43 @@ SettingRow { detail: root.spec ? root.spec.detail : "" 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 { + id: toggle + anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter checked: root.checked 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 + } } } diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index a5f171c..a7d9313 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -35,6 +35,7 @@ HealthCheckRow 1.0 HealthCheckRow.qml SegmentRow 1.0 SegmentRow.qml SettingRow 1.0 SettingRow.qml SettingsCard 1.0 SettingsCard.qml +SettingsNote 1.0 SettingsNote.qml SettingsButton 1.0 SettingsButton.qml SettingsShell 1.0 SettingsShell.qml SettingsSidebar 1.0 SettingsSidebar.qml diff --git a/config/dot/quickshell/services/Accessibility.qml b/config/dot/quickshell/services/Accessibility.qml index 1dbea13..1102efd 100644 --- a/config/dot/quickshell/services/Accessibility.qml +++ b/config/dot/quickshell/services/Accessibility.qml @@ -1,6 +1,6 @@ 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 // 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 // scale therefore affects applications, which is where it matters, and the // 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.Io @@ -93,4 +99,214 @@ Singleton { commands.push(["hyprctl", "setcursor", root.cursorTheme, size]); 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(); + } + } } diff --git a/config/dot/quickshell/services/Notifs.qml b/config/dot/quickshell/services/Notifs.qml index 1012270..c0e5d62 100644 --- a/config/dot/quickshell/services/Notifs.qml +++ b/config/dot/quickshell/services/Notifs.qml @@ -412,35 +412,71 @@ Singleton { // dozen overlapping bells, which is a noise rather than a notification. property real lastBellAt: 0 - function playBell(notification: var): void { - if (!SoundFeedback.eventSounds) - return; + // Emitted for every notification that is bell-eligible, whether or not a + // bell is actually audible. modules/notifications/VisualBell.qml listens. + 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 // application off: its notifications still arrive and still show, they // just stop making noise. if (!root.appRule(root.notificationAppId(notification)).sound) - return; + return false; // 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 // is the effective urgency, so "treat as low" is a way to keep an // application audible in principle but quiet in practice. if (root.effectiveUrgency(notification) === NotificationUrgency.Low) - return; + return false; - // The freedesktop sound hints. This is the fix for the double chime: - // an application that plays its own sound sets suppress-sound so the + // The freedesktop sound hint. This is the fix for the double chime: an + // application that plays its own sound sets suppress-sound so the // notification server stays quiet, and Panama ignoring it meant one // 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 ?? {}; if (hints["suppress-sound"] === true) + return false; + + return true; + } + + function playBell(notification: var): void { + if (!root.bellWouldRing(notification)) 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 soundName = String(hints["sound-name"] ?? ""); const candidates = soundFile.startsWith("/") diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 320a709..a63a98a 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -249,6 +249,21 @@ Singleton { { 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: "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: "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" }, diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index 3b0089b..3280a1f 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -89,6 +89,15 @@ ShellRoot { 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 {} // ── 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 { target: "activity" diff --git a/config/local/share/vicinae/scripts/settings-accessibility b/config/local/share/vicinae/scripts/settings-accessibility index 328538d..dac7aac 100755 --- a/config/local/share/vicinae/scripts/settings-accessibility +++ b/config/local/share/vicinae/scripts/settings-accessibility @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @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 diff --git a/docs/settings.md b/docs/settings.md index fc9e597..f716ee0 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -4,7 +4,7 @@ Do not edit this file. Run `quickshell/scripts/panama-settings-docs` 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 @@ -12,11 +12,12 @@ Found on **Accessibility**. | Setting | Default | What it does | |---|---|---| -| **Magnifier**
`magnifierFactor` `cursor:zoom_factor` | 1.0 | Magnifies the screen around the pointer. 1.0 is off. Range 1.0–5.0. | +| **Magnifier**
`magnifierFactor` `cursor:zoom_factor` | 1.0 × | Magnifies the screen around the pointer. 1.00 × is off. Range 1.0–5.0. | | **Magnifier follows in steps**
`magnifierRigid` `cursor:zoom_rigid` | false | Moves the magnified view in increments rather than gliding with the pointer | | **High contrast**
`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**
`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable | | **Dim amount**
`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.05–0.9. | +| **Flash the screen for notifications**
`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell | | **Pointer size**
`cursorSize` | 24 px | Applies to the compositor and to applications. Range 16–64. | | **Text size**
`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.75–2.0. | diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index e15387a..8f3db47 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -1470,3 +1470,205 @@ benefits from. `search-routing-contract`, `schema-hypr-shape-contract`, `preference-schema-contract`, `settings-docs-contract`, `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.0–5.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. diff --git a/docs/superpowers/specs/2026-08-24-accessibility-redesign.md b/docs/superpowers/specs/2026-08-24-accessibility-redesign.md new file mode 100644 index 0000000..51b0cc8 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-accessibility-redesign.md @@ -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.0–5.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. diff --git a/tests/quickshell/accessibility-contract b/tests/quickshell/accessibility-contract new file mode 100755 index 0000000..ed9d558 --- /dev/null +++ b/tests/quickshell/accessibility-contract @@ -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.*?)\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.*?)\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' diff --git a/tests/quickshell/notification-app-rules-contract b/tests/quickshell/notification-app-rules-contract index e003c64..e19ea5a 100755 --- a/tests/quickshell/notification-app-rules-contract +++ b/tests/quickshell/notification-app-rules-contract @@ -194,7 +194,23 @@ for (const required of ["rule.urgency === \"low\"", "rule.urgency === \"critical 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")) 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 diff --git a/tests/quickshell/osd-model-contract b/tests/quickshell/osd-model-contract index 276b462..d9cdf0b 100755 --- a/tests/quickshell/osd-model-contract +++ b/tests/quickshell/osd-model-contract @@ -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('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( model.progressState('volume', 140, 100, '', 900), { diff --git a/tests/quickshell/sound-page-contract b/tests/quickshell/sound-page-contract index 8dad129..dd760f2 100755 --- a/tests/quickshell/sound-page-contract +++ b/tests/quickshell/sound-page-contract @@ -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' 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' -rg -Fq 'label: "Speech server"' "$dictation_page" \ - || fail 'DictationPage does not report whether the speech server is installed' -rg -Fq 'label: "Speech model"' "$dictation_page" \ +# The Input-phase rebuild replaced the two literal TextRows with a status hero +# and setup steps, so the pin follows the state properties rather than the old +# 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' rg -Fq 'Dictation.typingAvailable' "$dictation_page" \ || fail 'DictationPage does not say when wtype is missing, so dictated text would silently go to the clipboard'