Make every settings row reachable, and every accessibility switch honest

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 21:03:36 -04:00
parent e1ff25fc66
commit 9ffaf45a4d
33 changed files with 2384 additions and 76 deletions
+3 -2
View File
@@ -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**<br>`magnifierFactor` `cursor:zoom_factor` | 1.0 | Magnifies the screen around the pointer. 1.0 is off. Range 1.05.0. |
| **Magnifier**<br>`magnifierFactor` `cursor:zoom_factor` | 1.0 × | Magnifies the screen around the pointer. 1.00 × is off. Range 1.05.0. |
| **Magnifier follows in steps**<br>`magnifierRigid` `cursor:zoom_rigid` | false | Moves the magnified view in increments rather than gliding with the pointer |
| **High contrast**<br>`highContrast` | false | Increases contrast in applications that support it. Modern GTK applications read this from the desktop portal and restyle themselves; older ones need a high-contrast theme, which is not installed here. |
| **Dim inactive windows**<br>`dimInactive` `decoration:dim_inactive` | false | Darkens every window except the focused one, so the active window is unmistakable |
| **Dim amount**<br>`dimStrength` `decoration:dim_strength` | 0.5 | How much darker unfocused windows are. Range 0.050.9. |
| **Flash the screen for notifications**<br>`visualAlerts` | false | A single flash at the edges of every screen when a notification arrives that would ring the bell |
| **Pointer size**<br>`cursorSize` | 24 px | Applies to the compositor and to applications. Range 1664. |
| **Text size**<br>`textScale` | 1.0 | Scales interface text everywhere; 1.00 is the design size. Range 0.752.0. |
@@ -1470,3 +1470,205 @@ benefits from.
`search-routing-contract`, `schema-hypr-shape-contract`,
`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.05.0.
The `accessibility` IPC handler in `shell.qml` is checked the same way.
- **The three binds, matched by target and verb rather than by chord.**
`SUPER+=` was already "Reset split", so which free chords the zoom keys take
was a decision made against `keybinds.lua` rather than against the mock — a
contract naming `SUPER+=` would have been wrong on the day it was written.
A separate check fails on any chord bound twice, which is the actual hazard:
Hyprland takes the last bind and the earlier action stops working silently.
- **The visual bell is one-shot.** No `loops: Animation.Infinite`, no `loops:
-1`, no Timer with `repeat: true` anywhere behind it, and it reads
`visualAlerts`.
- **The flash follows bell eligibility EXCEPT the event-sounds gate.** The
signal name is read out of `VisualBell.qml`'s `Connections { target: Notifs }`
rather than guessed, which also pins that the two are really wired. Two
structural checks then run over `Notifs.qml`: no `SoundFeedback.eventSounds`
early return may stand between the enclosing function's first line and the
emit, and the emit must sit on a path that DOES carry the shared gates
(`appRule(...).sound`, low urgency, `suppress-sound`) — otherwise the flash
would not be the bell seen, but a second louder notifier ignoring every
per-application rule somebody set. A third check fails if the file stops
explaining the exception, since an undocumented exception is one the next
reader helpfully removes.
- **Orca is a process, never a gsettings key.** `screen-reader-enabled` is
grepped for repo-wide across the shell's QML and JS, comment-stripped first.
Plus: `orcaRunning` exists, something `pgrep`s, and the probe is not on a
repeating Timer — a poll that runs all session to answer a question nobody
asked is a cost with no reader.
- **The nine primitives.** `Accessible.name`, `Accessible.role` and
`activeFocusOnTab` in each of `SliderRow`, `ToggleRow`, `SwitchRow`,
`ActionRow`, `ChoiceRow`, `SegmentRow`, `SettingRow`, `OptionPickerRow`,
`PickerRow`, resolved through the QML inheritance chain (a name inherited
from `SettingRow` is a name the row really has) — with one exception that is
NOT inheritable: a file drawing its own `MouseArea`/`SettingsToggle`/
`SettingsButton`/`ValueSlider` must declare its own `activeFocusOnTab`,
because `SettingRow` is a Tab stop only when `activatable`, which a
`ToggleRow` is not. Then: Space/Enter activation on the four rows that
activate, Left/Right on the two that hold a range, a focus indicator gated on
`activeFocus`, and — the visual-at-rest promise — no focus border painted
unconditionally, since these rows are used by every page.
- **The page, per the approved mock.** The five card titles, the five retired
ones absent, `dimStrength` gated on `dimInactive`, the mono-audio row present
and marked not-yet with somewhere to go meanwhile, the Keyboard jump going
through `openSettings()` rather than assigning `settingsPage` by hand, and no
`Process` on the page or its own components.
- **The honesty box is a statement, not an alarm.** It must name Hyprland (not
"Wayland"), say the absence was probed rather than assumed, and explain why
the GNOME switches are inert here — and it must NOT be painted in
`Theme.danger`/`Theme.warn` or use the words Warning, Error, Unsupported,
unfortunately, sorry, Broken. An accessibility page is the last page that can
afford to open with an alarm.
- **The dead zero label, swept across every settings page rather than only this
one.** `SliderRow.display()` substitutes `zeroLabel` at exactly 0, so a row
setting one for a setting whose schema minimum is above 0 is copy that can
never appear — which is what `zeroLabel: "Off"` was doing under a magnifier
whose minimum IS 1.0. Any page reintroducing that class of bug now fails.
- **Reduce motion is true of the shell**: `Theme.motionEnabled` reads
`Settings.animationsEnabled`, and all four duration tokens collapse to 0.
- **The cross-goal collision, which is silent.** `VisualBell` must not time its
flash with a `Theme.dur*` token: those now collapse to 0 under Reduce motion,
so a flash timed with `Theme.durFast` would be instantaneous and therefore
invisible — switching on Reduce motion would silently switch OFF Visual
alerts, for somebody quite likely to want both. A landed and pinned.
### Reconciled
- **`quickshell/notification-app-rules-contract` — RUN, PASS after a fix.** It
broke on the visual-alerts refactor: the bell's shared gates moved out of
`playBell` into the new `bellWouldRing` predicate, and the contract read only
`playBell`'s literal body, so it reported that the per-application sound
switch and the effective-urgency check had been dropped when both had merely
moved one call up. Now asserted over the bell's DECISION PATH (`playBell` +
`bellWouldRing`), plus a new assertion that `playBell` actually consults the
predicate — without which every gate would sit in a function nothing runs,
which reads exactly like a passing contract.
- **`quickshell/osd-model-contract` — RUN, PASS.** Gained the magnifier's OSD
kind: `iconFor('zoom', 0.4)` is `zoom-in-symbolic` and `iconFor('zoom', 0)` is
`zoom-original-symbolic`, since an empty bar is 1.00 ×, which is off rather
than barely magnified. Without a mapping the kind falls through to its own
name and the OSD draws the generic fallback glyph — for the one shortcut
whose whole job is telling somebody who cannot read the screen what the
magnification now is.
- **No contract pinned the Theme duration literals.** Checked by grep across
`tests/` for `durFast`/`durNormal`/`durSlow`/`durDockReveal` and
`motionEnabled`: zero hits before the change, so gating them broke nothing.
`accessibility-contract` now pins the gating itself, which is the first time
those tokens have been pinned anywhere.
- **`quickshell/settings-ownership-contract` — RUN, PASS unchanged.** Its
duplicate table expects exactly `animationsEnabled`, `cursorInactiveTimeout`,
`cursorSize` and `inactiveOpacity` to appear on two pages, and the rebuilt
page keeps all four, so the mirror set did not move.
### Seven search entries added
Magnifier zoom, Zoom in and out, Reduce motion, Visual alerts, Screen reader,
Orca, Sticky keys — all routing to `accessibility`. The schema already covers
the switches by their own labels, so these are only the words people arrive
with that no label uses: the verb rather than the noun ("zoom in", not
"Magnifier"), the application's name rather than its category ("Orca", not
"Screen reader"), and the schema's own wording gap ("Reduce motion", where the
label reads "Animations"; "Visual alerts", where it reads "Flash the screen for
notifications").
**High contrast was deliberately NOT added**, and the contract fails if it is:
it is already a schema label, and the index covers every schema label, so a
second copy would list the same setting twice in one result. The contract also
fails if any of these entries routes anywhere but `accessibility`.
### Schema docs and launcher commands regenerated
`quickshell/scripts/panama-settings-docs` and `panama-settings-commands` both
run without `--check` and their output committed. `docs/settings.md` goes 173 →
**174 settings across 36 groups**: `visualAlerts` renders in the accessibility
group, and `magnifierFactor` picks up its new `×` unit in both the default
column ("1.0 ×") and the detail ("1.00 × is off"). `settings-accessibility`'s
Vicinae keywords gained "flash the screen for notifications", "magnifier zoom",
"zoom in and out", "reduce motion" and "visual alerts".
`quickshell/settings-docs-contract` — RUN, PASS (174 settings documented);
`quickshell/panama-commands-contract` — RUN, PASS (77 commands; the
settings generator writes 38 of them).
### Deferred, and why
- **`quickshell/settings-pages-contract`, `settings-write-sweep-contract`,
`settings-search-contract`, `settings-buttons-contract` (live half),
`settings-preferences-contract`, `settings-commit-reset-contract`,
`settings-system-contract`, `settings-hyprland-write-contract` — NOT RUN**:
they daemonize a Quickshell harness or drive the live settings window. The
Accessibility page was rebuilt this phase, `SettingsNote.qml` is new, and all
nine shared row primitives changed, so this set is the end-of-redesign sweep
and matters more here than it did in any previous phase — a broken
`SliderRow` takes every settings page down, and only these would see it.
`qmldir-registration-contract` (static) was run and passes, so
`SettingsNote` at least resolves.
- **The whole live half of the zoom keybinds.** Nothing here presses a key. The
contract proves the binds exist, name the IPC verb, sit on unoccupied chords,
and that the path behind them commits through the preference; whether
Hyprland picks the new binds up needs a `hyprctl reload`, which this phase
deliberately did not run.
- **The flash has not been seen.** `VisualBell` is pinned structurally — one
animation, no loops, no repeating timer, gated on the preference, not timed
with a motion-gated token — but nobody has watched a notification arrive with
Visual alerts on. That is the one assertion here a screenshot would settle
and a grep cannot.
- **Orca has not been started or stopped.** The service is pinned as a process
probe with on-demand polling; whether `pgrep -x orca` matches the installed
Orca is A's verified claim in a comment (it calls `set_process_name("orca")`,
which renames `comm`), not something this contract re-checks.
- **The screen reader has not read the page.** `Accessible.name`, `role` and
`activeFocusOnTab` are present in all nine files and the key handlers are
there, but no AT-SPI client has walked the settings window to confirm the
names come out in a useful order. That needs Orca running against a live
shell.
- **`quickshell/sound-page-contract` fails, and it is not this work.** Verified
by stashing the whole working tree and running it against a clean checkout:
it fails identically ("DictationPage does not report whether the speech
server is installed"), so it was already red before this phase began.
- Run order for this phase: the static ones first (`accessibility-contract`,
`osd-model-contract`, `preference-schema-contract`,
`schema-hypr-shape-contract`, `settings-ownership-contract`,
`search-routing-contract`, `ipc-targets-contract`, `settings-jump-contract`,
`settings-docs-contract`, `readme-contract`, all eight of `tests/hypr/`),
then the sandboxed-harness ones, then the live settings window last.
@@ -0,0 +1,70 @@
# Accessibility redesign — everything that's real
Approved mock: `home-mocks/accessibility.html` (scratchpad, :8642). Spec wins over mock on
conflict. Tabless leaf stays.
## Goals
1. **Magnifier keybinds**: Super+= / Super+- / Super+0 step the zoom from anywhere, through the
shell so the stored preference stays truthful and the OSD shows the level. Mechanism: the
binds call `qs ipc call accessibility zoom in|out|reset`; a new IPC target routes to
`Accessibility.stepZoom()`, which computes (×1.25 steps, clamp to the schema's 1.05.0),
commits through the normal verified-preference path, and posts the OSD. No new script; a
dead shell means dead zoom keys, which is honest (everything else in the shell is dead too).
2. **Reduce motion becomes true**: `Theme.qml`'s `dur*` tokens gate on
`Settings.animationsEnabled` (0 when off) so the bar/dock/OSD/panels genuinely still.
3. **Visual alerts**: new schema key `visualAlerts` (bool, def false, group `accessibility`);
when on, a one-shot screen-edge flash (per-screen overlay, single animation per event,
never looping) fires wherever `Notifs.playBell` decides a notification sounds (including
when the audible bell is skipped for `sound: false` apps? No — the flash follows the same
eligibility as the bell EXCEPT the eventSounds gate: visual alerts are for people who can't
hear the bell, so the flash fires on bell-eligible notifications even when event sounds are
off. Pin that.)
4. **Honest Orca control**: running state (pgrep poll on page open + after actions),
Start/Stop; the a11y-bus readiness line. No gsettings screen-reader toggle (gsd-owned,
inert — refused).
5. **The primitives get accessible**: `SliderRow`, `ToggleRow`, `SwitchRow`, `ActionRow`,
`ChoiceRow`, `SegmentRow`, `SettingRow` (activatable), `OptionPickerRow`, `PickerRow`
gain `Accessible.role`/`Accessible.name` (from label; detail as description),
`activeFocusOnTab` on the interactive element, key handling (Space/Enter activates,
Left/Right steps sliders and segments), and a visible focus indicator (the titlebar
close-button border pattern). No API or visual-at-rest changes.
6. **Page rebuilt** per mock: Vision / Motion / Hearing / Keyboard & pointer / Screen reader;
`dimStrength` gated on `dimInactive`; magnifier "1× is off" honesty (schema gains
`unit: "×"`-style display via the page detail — fix the dead `zeroLabel`); the honesty box
for sticky/slow/bounce with the probed-not-assumed copy; mono-audio NOT YET row; "Open
Keyboard" jump.
7. Cleanups: `DESKTOP-PARITY.md`'s stale AccessX claim corrected; search entries for
magnifier/zoom/orca/screen reader/high contrast/reduce motion/visual alerts.
Non-goals: mono audio (deferred with the on-page honesty), sticky/slow/bounce/hover/mouse
keys (refused with receipts), gsettings `enable-animations`/`screen-reader-enabled` writes
(inert), shell text scaling.
## Ownership
- **A**: `config/PreferenceSchema.qml` (visualAlerts; magnifierFactor detail/unit fix),
`config/dot/hypr/keybinds.lua` (three zoom binds — CHECK for collisions with existing
Super+=/-/0 binds first; pick free chords and update the mock copy via B if needed),
`shell.qml` (accessibility IPC target), `services/Accessibility.qml` (stepZoom, orcaRunning
+ start/stop, poll discipline), `services/Notifs.qml` (flash trigger signal per goal 3),
NEW `modules/notifications/VisualBell.qml` (+ per-screen wiring in shell.qml),
`config/dot/hypr/DESKTOP-PARITY.md` (stale claim), `services/OsdState.qml`/`OsdModel.js`
only if the zoom OSD needs a new kind (coordinate with C on osd-model-contract).
- **B**: the nine row primitives (accessibility additions only — zero API/visual-at-rest
change), `modules/settings/AccessibilityPage.qml` rebuild (+ any new components + qmldir).
Reuse KeycapChord for the chord display.
- **C**: `services/SettingsSearch.qml`, NEW `tests/quickshell/accessibility-contract` (page
structure; zoom IPC path commits through the verified-preference path and never calls
hyprctl keyword; visual bell one-shot — no `loops: Animation.Infinite`, no Timer-driven
repeat; flash follows bell eligibility but not the eventSounds gate; primitives carry
Accessible.name + activeFocusOnTab — sweep all nine files; Orca control uses process state,
never gsettings), `osd-model-contract` (zoom kind if added), reconcile any contract pinning
Theme durations or the primitives' file contents, backlog Phase 14, README count
(174 → 175 expected), docs/commands regen at the end (schema changed).
Hard rules: no live mutations (no zoom application, no orca start/stop, no gsettings writes,
no hyprctl keyword/reload); hermetic stubs only; valid QML at every save (primitives are used
by EVERY page — a broken SliderRow takes the whole settings app down, so B edits them one at
a time with a reload-check between each). B programs against A's pinned APIs; A updates this
spec before changing them.