Say what each control is, so a screen reader can use one

The whole interface carried five accessible-* declarations in 14,482 lines of
markup, all of them on a single row of the colour mixer, and nothing said so.
Every other control — every button, every tick-box, every chip, and the slider
the develop panel builds thirty-six of for the mixer alone — reached AT-SPI
and TalkBack as an unnamed rectangle. NFR-A11Y-2 is not a polish item for a
user in that position; it is whether the application can be used at all.

The annotations go on the shared components rather than on the screens, which
is the same argument widgets.slint was written to make one layer down: a
control named where it is used is a control unnamed everywhere it is used
next. Twelve components now declare a role, a name and — where the control
does something — the action assistive technology invokes to do it. Three
screens were touched, and only where the component could not know the answer.

SliderTrack is the one that mattered most and the one that could not be fixed
from inside itself. It is handed four numbers and knows nothing about what
they mean, so it takes a `label` and a formatted `readout` and every wrapper
passes down what it was already drawing. A test asserts that every
instantiation does, because a track added without one announces "slider, 0.35"
and looks perfectly correct in a screenshot.

It also gains increment, decrement and set-value. A slider that can only be
dragged is a slider a pointer is a modifier for, which is the objection
ui-navigation D-N2 makes about hover-only affordances with the argument run
one step further; these three are what a screen reader drives a slider with,
and they commit as well as change — one nudge is a whole gesture, so a caller
that persists on `committed` must hear about it.

Two decisions worth recording because the obvious alternative is wrong:

`active` on Button and IconButton is deliberately not announced from the
component. It says a toggle is on and says nothing about whether a control
that is *off* is a toggle at all, so announcing it would report every button
in the application as an unpressed toggle. The four call sites that mean a
toggle say so themselves, which Slint permits because the role is inherited.

SwatchSlider's row-level role is removed rather than kept. It was the one
control that had a label, and now that the track underneath it has one too the
two would nest — a slider inside a slider, the outer holding the value and the
inner holding the actions that can change it. The row stands down and hands
the same strings to the control that owns the gesture.

The test reads the markup the way darkroom-android's manifest test reads its
XML: there is no accessibility tree without a window, so what it defends is
the failure that actually happens — a role or a name lost in a refactor, which
compiles, renders identically, and is invisible to everyone not using a screen
reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 10:22:32 +02:00
co-authored by Claude Opus 5
parent ef07e6ca3e
commit 8e4befa727
5 changed files with 657 additions and 10 deletions
+56 -10
View File
@@ -140,6 +140,18 @@ component GroupHeading inherits Rectangle {
width: 34px;
visible: root.has-reset;
// A `Caption` is a `Text`, which Slint announces as text — so
// without this the reset reads as the word "reset" sitting beside
// the heading rather than as something that can be pressed.
accessible-role: button;
accessible-label: "Reset";
accessible-enabled: root.has-reset;
accessible-action-default => {
if (root.has-reset) {
root.reset();
}
}
reset-touch := TouchArea {
width: 100%;
height: max(parent.height, Theme.touch-target);
@@ -202,6 +214,18 @@ component ParamSlider inherits Rectangle {
modified: root.data.value != root.data.default-value;
SliderTrack {
// The same two strings `ControlRow` is drawing above the track.
// Drawn text and announced text are separate channels — Slint
// associates neither with the control on its own — so the label a
// sighted user reads and the label a screen reader hears come from
// one expression each, rather than the second being left empty.
label: root.data.param-label;
readout: Readout.of(root.data);
// The descriptor's declared precision, as a step: a parameter in
// whole units nudges by one and one in stops by a hundredth,
// which is the same quantum the readout is rounded to.
step: root.data.precision == 0 ? 1.0 : 0.01;
value: root.data.value;
default-value: root.data.default-value;
minimum: root.data.minimum;
@@ -245,12 +269,18 @@ component FacetHeading inherits Rectangle {
// most of a screen of scrolling with the band name absent from every row of it
// anyway.
//
// **The name is not thrown away, it moves.** It is the row's accessible label,
// so a screen reader says "Orange" where the eye reads the colour, and the
// catalogue in `labels.rs` is where the mapping is written down for anyone who
// cannot separate two squares by eye. A row identified by colour *alone*
// **The name is not thrown away, it moves.** It becomes the track's accessible
// label, so a screen reader says "Orange" where the eye reads the colour, and
// the catalogue in `labels.rs` is where the mapping is written down for anyone
// who cannot separate two squares by eye. A row identified by colour *alone*
// would be a control some photographers could not use, which is why the
// spoken name is part of the design and not an afterthought.
//
// It used to be announced on this row, with the track below it silent. Now
// that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a
// slider inside a slider, the outer one carrying the value and the inner one
// carrying the actions that can change it — so the row stands down and hands
// the same three strings to the control that owns the gesture.
component SwatchSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
@@ -264,12 +294,6 @@ component SwatchSlider inherits Rectangle {
// and the gestures behind it (FR-UI-3).
height: Theme.touch-target / 2 + 4px;
accessible-role: slider;
accessible-label: root.data.param-label;
accessible-value: Readout.of(root.data);
accessible-value-minimum: root.data.minimum;
accessible-value-maximum: root.data.maximum;
HorizontalLayout {
padding-left: Theme.gap-sm;
spacing: Theme.gap-sm;
@@ -284,6 +308,8 @@ component SwatchSlider inherits Rectangle {
SliderTrack {
horizontal-stretch: 1;
label: root.data.param-label;
readout: Readout.of(root.data);
value: root.data.value;
default-value: root.data.default-value;
minimum: root.data.minimum;
@@ -346,6 +372,10 @@ component PlainSlider inherits Rectangle {
modified: root.value != root.default-value;
SliderTrack {
label: root.label;
readout: (Math.round(root.value * 10) / 10) + root.unit;
step: 0.1;
value: root.value;
default-value: root.default-value;
minimum: root.minimum;
@@ -575,16 +605,26 @@ export component GeometryPanel inherits Rectangle {
// Rotation and flips. Icons rather than labels: four controls
// named in words would wrap the 280px column, and each of
// these shows its own result.
//
// The words are still written, once each, as `label` — the width
// argument is about the column, and a screen reader has no column.
// The two flips also declare themselves checkable, which
// `IconButton` cannot do on its own: `active` says a toggle is on
// and says nothing at all about whether an inactive control is a
// toggle that is off, so only these call sites know that the two
// rotations are not toggles and these two are.
HorizontalLayout {
spacing: Theme.gap-sm;
IconButton {
icon: "rotate-ccw";
label: "Rotate left";
enabled: root.enabled;
clicked => { root.rotate(-1); }
}
IconButton {
icon: "rotate-cw";
label: "Rotate right";
enabled: root.enabled;
clicked => { root.rotate(1); }
}
@@ -593,13 +633,19 @@ export component GeometryPanel inherits Rectangle {
IconButton {
icon: "flip-h";
label: "Flip horizontally";
active: root.flip-h;
accessible-checkable: true;
accessible-checked: root.flip-h;
enabled: root.enabled;
clicked => { root.flip-h-toggled(); }
}
IconButton {
icon: "flip-v";
label: "Flip vertically";
active: root.flip-v;
accessible-checkable: true;
accessible-checked: root.flip-v;
enabled: root.enabled;
clicked => { root.flip-v-toggled(); }
}