From 8e4befa727688e3be202c4b0739f1b02b26d1871 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 10:22:32 +0200 Subject: [PATCH 1/6] Say what each control is, so a screen reader can use one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ui/dr-ui/tests/ui_controls_are_accessible.rs | 317 +++++++++++++++++++ ui/dr-ui/ui/adjust.slint | 66 +++- ui/dr-ui/ui/controls.slint | 129 ++++++++ ui/dr-ui/ui/identity.slint | 8 + ui/dr-ui/ui/widgets.slint | 147 +++++++++ 5 files changed, 657 insertions(+), 10 deletions(-) create mode 100644 ui/dr-ui/tests/ui_controls_are_accessible.rs diff --git a/ui/dr-ui/tests/ui_controls_are_accessible.rs b/ui/dr-ui/tests/ui_controls_are_accessible.rs new file mode 100644 index 0000000..15d7ada --- /dev/null +++ b/ui/dr-ui/tests/ui_controls_are_accessible.rs @@ -0,0 +1,317 @@ +// TRACES: NFR-A11Y-2 +//! Every shared control says what it is, and every slider says what it edits. +//! +//! NFR-A11Y-2 asks that controls expose names, roles and values to AT-SPI and +//! TalkBack. Almost none of that is Rust: it is `accessible-*` properties in +//! Slint markup, which no unit test can observe by running the interface — +//! there is no accessibility tree without a window, and CI has no display. +//! +//! So this reads the markup, the way `darkroom-android`'s manifest test reads +//! the XML that aapt2 owns and rustc never sees. The property being defended +//! is not "the screen reader announces the right words", which needs a device +//! and a person; it is the thing that silently regresses instead — **a control +//! losing its role or its name in an ordinary refactor**, which compiles, +//! renders identically, passes every other test, and is invisible to anyone +//! not using a screen reader. That failure has already happened once here: the +//! whole application had five `accessible-*` lines in 14,482 lines of markup, +//! all of them on one row of the colour mixer, and nothing said so. +//! +//! ## What is checked, and what deliberately is not +//! +//! Two properties, both structural: +//! +//! 1. **Named components declare the roles they promise.** A `Button` that +//! stops declaring `accessible-role` stops existing for a screen reader +//! entirely, because Slint rejects every other `accessible-*` property that +//! is not accompanied by a role — so the role is the load-bearing one and +//! the rest fail with it. +//! +//! 2. **Every `SliderTrack` instantiation supplies a `label`.** This is the +//! one that catches a *new* control rather than a broken old one. The track +//! cannot name itself — it has no idea what number it is dragging — so an +//! unnamed one announces "slider, 0.35" and is the single most-used control +//! in the application. A track added without a label is the exact mistake +//! this file exists to make loud. +//! +//! Not checked: whether the words are good, whether they are translated, +//! whether contrast passes, or whether Android exposes any of it — spike S13 +//! is still unrun and no test on this side of it can answer that. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// A component that must carry particular `accessible-*` properties, and the +/// properties it must carry. +/// +/// Only the ones whose absence is a behavioural loss are listed. `Button` must +/// declare `accessible-action-default` because without it a screen reader can +/// read the button and not press it, which is a control that exists and cannot +/// be used; it need not declare `accessible-description`, which is a nicety. +struct Promise { + file: &'static str, + component: &'static str, + properties: &'static [&'static str], +} + +const PROMISES: &[Promise] = &[ + Promise { + file: "widgets.slint", + component: "Button", + properties: &["accessible-role", "accessible-label", "accessible-action-default"], + }, + Promise { + file: "widgets.slint", + component: "IconButton", + properties: &["accessible-role", "accessible-label", "accessible-action-default"], + }, + Promise { + file: "widgets.slint", + component: "FilterChip", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, + Promise { + file: "widgets.slint", + component: "Section", + properties: &["accessible-role", "accessible-label", "accessible-expanded"], + }, + Promise { + file: "widgets.slint", + component: "ProgressBar", + properties: &["accessible-role", "accessible-label", "accessible-value"], + }, + // The label goes on the `TextInput` inside, not on the box around it — + // Slint gives that element its role, so the name belongs beside it. + Promise { + file: "widgets.slint", + component: "Field", + properties: &["accessible-label", "accessible-placeholder-text"], + }, + Promise { + file: "controls.slint", + component: "SliderTrack", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-value", + "accessible-value-minimum", + "accessible-value-maximum", + "accessible-action-increment", + "accessible-action-decrement", + "accessible-action-set-value", + ], + }, + Promise { + file: "controls.slint", + component: "Check", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, + Promise { + file: "controls.slint", + component: "ChoiceChip", + properties: &[ + "accessible-role", + "accessible-label", + "accessible-checked", + "accessible-action-default", + ], + }, +]; + +fn ui_dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("ui") +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())) +} + +/// The body of one top-level component declaration, or `None` if the file +/// declares no such component. +/// +/// Every component in these files is declared at column zero, so the block +/// runs from its header to the next header or to the end of the file. A +/// looser rule than brace matching and a stricter one than "somewhere in the +/// file": the point is that a property found for `Button` really is inside +/// `Button` and not inside the component below it. +fn component_body<'a>(source: &'a str, name: &str) -> Option<&'a str> { + let header_starts = |line: &str| { + line.starts_with("component ") || line.starts_with("export component ") + }; + + let mut start = None; + for (offset, line) in line_offsets(source) { + if !header_starts(line) { + continue; + } + // `component Foo inherits Bar {` — the word after `component`. + let declared = line + .trim_start_matches("export ") + .trim_start_matches("component ") + .split_whitespace() + .next(); + + match start { + None if declared == Some(name) => start = Some(offset), + Some(from) => return Some(&source[from..offset]), + None => {} + } + } + start.map(|from| &source[from..]) +} + +/// Each line of `source` with its byte offset. +fn line_offsets(source: &str) -> impl Iterator { + let mut offset = 0; + source.lines().map(move |line| { + let at = offset; + offset += line.len() + 1; + (at, line) + }) +} + +/// Whether a block sets a property — the name at the start of a line, followed +/// by `:` for a property or `=>` for a callback. +/// +/// Anchored at the start of the trimmed line so that a *mention* in the prose +/// above a component does not count. These files carry more comment than code, +/// and several of those comments name the very properties asserted here; a +/// substring search would pass on the strength of an explanation of the thing +/// that had been deleted, which is the failure `ui_names_no_operation.rs` +/// documents at length and the android manifest test strips comments to avoid. +fn sets(block: &str, property: &str) -> bool { + block.lines().any(|line| { + let line = line.trim_start(); + line.strip_prefix(property) + .is_some_and(|rest| rest.starts_with(':') || rest.trim_start().starts_with("=>")) + }) +} + +#[test] +fn shared_controls_declare_their_roles() { + let ui = ui_dir(); + let mut failures = Vec::new(); + + for promise in PROMISES { + let path = ui.join(promise.file); + let source = read(&path); + + let Some(body) = component_body(&source, promise.component) else { + failures.push(format!( + " {} declares no component `{}` — it was renamed or removed, and \ + this test then checks nothing", + promise.file, promise.component + )); + continue; + }; + + for property in promise.properties { + if !sets(body, property) { + failures.push(format!( + " {}: `{}` does not set `{property}`", + promise.file, promise.component + )); + } + } + } + + assert!( + failures.is_empty(), + "\n\nNFR-A11Y-2: controls expose names, roles and values to the platform \ + accessibility layer.\n\n{}\n\n\ + Slint rejects every `accessible-*` property that is not accompanied by an \ + `accessible-role`, so the role is what makes the control exist for AT-SPI \ + and TalkBack at all, and the actions are what make it usable rather than \ + merely readable.\n\n\ + These live on the shared components rather than at the call sites \ + deliberately: a control annotated where it is used is a control unnamed \ + everywhere it is used next. See the preamble to widgets.slint.\n", + failures.join("\n") + ); +} + +#[test] +fn every_slider_track_is_named() { + let ui = ui_dir(); + let mut files: Vec = fs::read_dir(&ui) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", ui.display())) + .map(|entry| entry.expect("read dir entry").path()) + .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("slint")) + .collect(); + files.sort(); + + let mut instantiations = 0usize; + let mut unnamed = Vec::new(); + + for path in &files { + let source = read(path); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?"); + + // The declaration itself, in controls.slint, is not an instantiation. + for (offset, line) in line_offsets(&source) { + if line.trim_start() != "SliderTrack {" { + continue; + } + instantiations += 1; + + let block = &source[offset..offset + block_len(&source[offset..])]; + if !sets(block, "label") { + let number = source[..offset].lines().count() + 1; + unnamed.push(format!(" {name}:{number}")); + } + } + } + + // A scan that found nothing passes for the wrong reason. Four tracks are + // instantiated today — the develop panel's three shapes and the settings + // page's row — and the floor sits below that and well above zero, so + // removing one is a code change and not a silent scan failure. + assert!( + instantiations >= 3, + "found only {instantiations} `SliderTrack` instantiations — the scan is \ + matching nothing, and a scan over nothing passes" + ); + + assert!( + unnamed.is_empty(), + "\n\nNFR-A11Y-2: a slider that does not say what it adjusts.\n\n{}\n\n\ + `SliderTrack` cannot name itself — it is handed four numbers and knows \ + nothing about what they mean — so a track without a `label` announces as \ + \"slider, 0.35\". The develop column holds thirty-six of them in the \ + colour mixer alone, which is thirty-six controls a screen reader cannot \ + tell apart.\n\n\ + Pass the same string the row is already drawing above the track.\n", + unnamed.join("\n") + ); +} + +/// The length of the brace-delimited block beginning at the start of `rest`, +/// including both braces. +/// +/// Braces inside string literals are not handled, and do not occur inside a +/// `SliderTrack` instantiation — the only strings there are labels and units. +fn block_len(rest: &str) -> usize { + let mut depth = 0usize; + for (i, c) in rest.char_indices() { + match c { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return i + 1; + } + } + _ => {} + } + } + rest.len() +} diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index 17dbaf1..ae2a438 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -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 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(); } } diff --git a/ui/dr-ui/ui/controls.slint b/ui/dr-ui/ui/controls.slint index 0282927..c3b1296 100644 --- a/ui/dr-ui/ui/controls.slint +++ b/ui/dr-ui/ui/controls.slint @@ -22,6 +22,16 @@ // primitives take plain numbers and strings, and the ParamRow-shaped wrappers // stay in the panel that owns the model. This is the constraint that makes the // file reusable, so it is worth stating rather than merely observing. +// +// **Accessibility follows the same rule as behaviour** (NFR-A11Y-2, and see +// widgets.slint's preamble for the two Slint constraints that shape it): a +// control's role, and the actions assistive technology can invoke on it, are +// written once here. Its *name* is the one thing that cannot be — a track has +// no idea what number it is dragging — so every primitive below takes a +// `label`, and the wrappers that do know pass it down. An unnamed control is +// the failure mode that matters: a screen reader announcing "slider, 0.35" for +// each of the thirty-six controls in the colour mixer has told the user +// nothing at all. import { Theme } from "theme.slint"; import { Icon, Label, Value, Caption, Field } from "widgets.slint"; @@ -49,6 +59,30 @@ export component SliderTrack inherits Rectangle { in property minimum; in property maximum; + /// What this track adjusts. The track's accessible name. + /// + /// Every wrapper already draws this word somewhere — `ControlRow` puts it + /// above, `FieldRow` puts it above and to the left — and none of those + /// placements associates it with the control as far as the platform is + /// concerned. `SwatchSlider` is the case that makes the point: it draws no + /// word at all, only a coloured square, and its name has *always* had to + /// travel this way. + in property label; + /// The value as the user should hear it, already formatted. + /// + /// Empty falls back to the raw number, which is right for a track whose + /// caller has nothing better; a caller with a declared precision and a + /// unit hands over what it is drawing, so "+1.25 EV" is announced rather + /// than "1.2500000298". + /// + /// A string rather than a float for the reason `ControlRow.readout` gives: + /// precision belongs to whoever owns the value, and a control that rounded + /// on its own would announce a parameter one way and draw it another. + in property readout; + /// How far one assistive-technology nudge moves the value. Zero takes a + /// hundredth of the range, which is the resolution a drag has anyway. + in property step: 0; + /// Live, once per movement. For anything that should follow the drag: a /// readout, a preview, the image itself. callback changed(float); @@ -76,6 +110,47 @@ export component SliderTrack inherits Rectangle { // by nothing and put every position at infinity. property span: max(0.000001, root.maximum - root.minimum); + property nudge: root.step > 0 ? root.step : root.span / 100; + + // **One nudge is a whole gesture, so it commits.** + // + // The two callbacks exist because a drag is many movements and one + // decision (see `committed` above). An arrow key pressed once is both at + // the same time: there is no stream to debounce and no release to wait + // for, so a nudge that only fired `changed` would move the photograph and + // never be saved by any caller that listens for the end of a drag — which + // is every settings-shaped caller in the application. + function move-to(v: float) { + root.changed(clamp(v, root.minimum, root.maximum)); + root.committed(clamp(v, root.minimum, root.maximum)); + } + + // **The only route to this control that is not a pointer.** ui-navigation + // D-N2 rules out hover as the sole affordance; a control reachable only by + // dragging it is the same objection with the pointer itself as the + // modifier. These three actions are what AT-SPI and TalkBack drive a + // slider with, and they are what makes the track adjustable rather than + // merely readable. + accessible-role: slider; + accessible-label: root.label; + // Both arms of the ternary must be strings — the empty concatenation is + // what makes the fallback one. + accessible-value: root.readout != "" ? root.readout : (root.value + ""); + accessible-value-minimum: root.minimum; + accessible-value-maximum: root.maximum; + accessible-value-step: root.nudge; + accessible-action-increment => { root.move-to(root.value + root.nudge); } + accessible-action-decrement => { root.move-to(root.value - root.nudge); } + accessible-action-set-value(v) => { + // `is-float()` for the reason `NumberField` gives at length: + // `to-float()` answers 0 for a string it could not parse, and a + // screen reader handing over "abc" would silently set the exposure to + // zero rather than reject the entry. + if (v.is-float()) { + root.move-to(v.to-float()); + } + } + // **Why hover, and not the drag itself.** // // A Flickable does not merely compete for a gesture, it *withholds* the @@ -258,6 +333,9 @@ export component NumberField inherits Rectangle { in property value; in property minimum; in property maximum; + /// What the number means. Handed straight to the entry, which is where the + /// accessibility tree wants it — see `Field.label`. + in property label; /// Decimal places shown, and the precision an entry is held to. Zero for a /// count, two for a value in stops — the same figure a parameter /// descriptor declares. @@ -303,6 +381,7 @@ export component NumberField inherits Rectangle { field := Field { width: 100%; height: 100%; + label: root.label; text <=> root.text; // Committed on Enter *and* on losing focus, matching `TextRow`: Enter // alone loses the edit the moment the user clicks the next control, @@ -344,6 +423,21 @@ export component Check inherits Rectangle { callback toggled(bool); + // The hint becomes the description rather than part of the name. It exists + // to say what a setting *costs* — "location is stripped", "upscaling is + // off" — which is the second thing a reader wants and never the first, and + // a name that carried it would read the whole sentence back on every pass + // through the page. + accessible-role: checkbox; + accessible-label: root.label; + accessible-description: root.hint; + accessible-checkable: true; + accessible-checked: root.checked; + accessible-action-default => { + root.checked = !root.checked; + root.toggled(root.checked); + } + height: max(row.preferred-height, Theme.control-height); touch := TouchArea { @@ -408,6 +502,31 @@ export component ChoiceChip inherits Rectangle { callback clicked(); + // `radio-button` and not `button`, because single-selection over a fixed + // list is what a radio button *is* — and the difference is audible: a + // reader announcing "radio button, selected" has told the user that + // picking another one will unpick this, which "button, pressed" has not. + // It is the same distinction the prose above draws against `FilterChip`, + // said in the vocabulary the platform already has a word for. + // + // **No `radio-group` around them.** The role exists, and the natural home + // for it — `Segmented` — is a `VerticalLayout` rather than the plain root + // Slint's own `RadioGroupBase` carries it on, and `Segmented` delegates to + // `ChipGrid` for a wrapped set, so the group would either sit on a layout + // element or be declared twice and nest. The group's name still reaches a + // reader: `FieldRow` draws it as a `Text`, which Slint exposes on its own, + // immediately before the chips in traversal order. + accessible-role: radio-button; + accessible-label: root.label; + accessible-enabled: root.enabled; + accessible-checkable: true; + accessible-checked: root.selected; + accessible-action-default => { + if (root.enabled) { + root.clicked(); + } + } + height: Theme.control-height; // Wide enough that a one-word label is still a comfortable target, which // is what `control-min-width` exists for — but chips sit several to a row, @@ -612,6 +731,7 @@ export component TextRow inherits VerticalLayout { field := Field { width: 100%; + label: root.label; text <=> root.text; placeholder: root.placeholder; // Committed on Enter *and* on losing focus. Enter alone loses @@ -755,6 +875,14 @@ export component SliderRow inherits VerticalLayout { // tall where the track is half of one. y: (parent.height - self.height) / 2; + label: root.label; + // A declared precision is a declared step — the argument above, + // reused. The nudge an assistive technology makes is therefore the + // same quantum a drag snaps to, so arrowing to a value and + // dragging to it produce the same number rather than two that + // differ in the last place. + step: 1.0 / root.step-factor; + value: root.live; default-value: root.default-value; minimum: root.minimum; @@ -768,6 +896,7 @@ export component SliderRow inherits VerticalLayout { NumberField { // Follows the drag, so the number and the handle never disagree. value: root.live; + label: root.label; minimum: root.minimum; maximum: root.maximum; precision: root.precision; diff --git a/ui/dr-ui/ui/identity.slint b/ui/dr-ui/ui/identity.slint index 7899f75..ace70f6 100644 --- a/ui/dr-ui/ui/identity.slint +++ b/ui/dr-ui/ui/identity.slint @@ -155,12 +155,19 @@ component FaceCell inherits Rectangle { // judgement, and the two are never conflated. A // rejection is remembered, so the face is not suggested // for that person again. + // The gesture note above is the whole label: a tick and a cross + // are only "confirm" and "reject" to someone who can see the + // suggestion they sit beside, and `IconButton`'s fallback would + // announce them as "check" and "cross" — two icon names that say + // nothing about which person is being ruled on. if !face.confirmed: IconButton { icon: "check"; + label: "Confirm this face"; clicked => { root.confirm(); } } if !face.confirmed: IconButton { icon: "cross"; + label: "Reject this face"; clicked => { root.reject(); } } if face.confirmed: Text { @@ -750,6 +757,7 @@ export component IdentityScreen inherits Rectangle { Rectangle { } IconButton { icon: "rotate-cw"; + label: "Recheck coverage"; clicked => { root.check-coverage(); } } } diff --git a/ui/dr-ui/ui/widgets.slint b/ui/dr-ui/ui/widgets.slint index 18c32a6..e7ef6c2 100644 --- a/ui/dr-ui/ui/widgets.slint +++ b/ui/dr-ui/ui/widgets.slint @@ -13,6 +13,21 @@ // `surface` is; only this file says what a *panel heading* is — and until it // did, four screens each re-derived one, which is exactly how a single accent // colour reached forty call sites with no single place to change it. +// +// **The same argument decides where accessibility lives** (NFR-A11Y-2). What +// AT-SPI and TalkBack are handed — a role, a name, a value, and an action they +// can invoke — is a property of *what a control is*, not of where it happens +// to be used, so it is declared once here and at the call site only where the +// call site knows something the component cannot. A button annotated in forty +// places is a button unnamed in thirty-nine of them, which is the failure this +// file was written to stop, one layer down. +// +// Two Slint rules shape what that can look like. `accessible-role` must be a +// *constant* — a ternary over a runtime property is a compile error — so a +// component that would need two roles is two components. And every other +// `accessible-*` property is rejected unless a role is set beside it or on the +// element it inherits from; that inheritance is what lets a call site add +// `accessible-checkable` to a `Button` whose role was set here. import { Theme } from "theme.slint"; import { Icon } from "icons.slint"; @@ -42,10 +57,31 @@ export component Button inherits Rectangle { /// Sustained state — a toggle that is currently on, not a press. The same /// meaning [`IconButton`] gives it, so a labelled toggle and an icon /// toggle read alike. + /// + /// **Deliberately not exposed to assistive technology from here.** Only + /// the call site knows whether a button that is currently *not* active is + /// a toggle that is off or an ordinary button that has no such state, and + /// announcing every button in the application as an unpressed toggle is + /// worse than announcing none of them. A call site that means a toggle + /// says so — `accessible-checkable: true; accessible-checked: ;` — + /// which Slint permits because the role below is inherited. in property active: false; callback clicked(); + accessible-role: button; + accessible-label: root.text; + accessible-enabled: root.enabled; + // The action a screen reader invokes, and it goes around the TouchArea + // rather than through it — so the `enabled` gate the TouchArea applies to + // a pointer has to be applied again here, or a disabled button would be + // pressable by exactly the users who cannot see that it is greyed out. + accessible-action-default => { + if (root.enabled) { + root.clicked(); + } + } + height: Theme.control-height; // A minimum rather than a fixed width: callers that set `width` or hand // this to a stretching layout still win, and a long label is not clipped. @@ -108,12 +144,40 @@ export component Button inherits Rectangle { export component IconButton inherits Rectangle { /// A name from the [`Icon`] vocabulary. in property icon; + /// What the drawing means, in words. + /// + /// A `Button` gets its accessible name for nothing, out of the text it was + /// already drawing. This control draws no text at all, so the name has to + /// be given — and an icon button without one is not a degraded experience + /// for a screen-reader user, it is an unusable one. + /// + /// Left empty it falls back to the icon's own name, which is a bad label + /// ("chevron-right") and still a better answer than silence. That is the + /// bargain `labels::resolve` already strikes for a key nobody has + /// catalogued, and it is struck here for the same reason: a control added + /// today should be reachable before someone has written its word. + in property label; in property enabled: true; /// Sustained state — a toggle that is currently on, not a press. + /// + /// Not announced from here, for the reason [`Button`]'s copy of this + /// property gives: the component cannot tell an off toggle from a button + /// with no state, so the call site that means a toggle sets + /// `accessible-checkable: true` and `accessible-checked` beside its + /// `active`. in property active: false; callback clicked(); + accessible-role: button; + accessible-label: root.label != "" ? root.label : root.icon; + accessible-enabled: root.enabled; + accessible-action-default => { + if (root.enabled) { + root.clicked(); + } + } + width: Theme.control-height; height: Theme.control-height; horizontal-stretch: 0; @@ -178,6 +242,21 @@ export component FilterChip inherits Rectangle { callback clicked(); + // Unlike [`Button`], this one *can* say it is a toggle without asking the + // call site, because being one is the whole of what distinguishes it — + // "it is *state*, not an action", two paragraphs up. So `checkable` is + // unconditional and an inactive chip announces as unpressed rather than + // as an ordinary button. + // + // The count is not folded into the name. It is drawn as a `Text`, which + // Slint already exposes as its own node, so a reader reaches it by moving + // one step further rather than by hearing a bare number glued to a word. + accessible-role: button; + accessible-label: root.label; + accessible-checkable: true; + accessible-checked: root.active; + accessible-action-default => { root.clicked(); } + height: Theme.control-height - 4px; // A floor on a content-sized chip, expressed as one property: Slint rejects // `width` and `min-width` together, and the floor is what keeps a chip @@ -304,6 +383,15 @@ export component Section inherits Rectangle { /// Whether this section's contents can be reset at all. in property has-reset: true; + accessible-role: groupbox; + accessible-label: root.title; + accessible-expandable: true; + accessible-expanded: root.expanded; + accessible-action-expand => { + root.expanded = !root.expanded; + root.toggled(root.expanded); + } + background: transparent; // Own height comes from the layout below, so a collapsed section shrinks // to its header. @@ -372,6 +460,22 @@ export component Section inherits Rectangle { Rectangle { width: 28px; + // The reset is drawn only under the pointer, which + // ui-navigation.md D-N2 rules out as the *only* route to + // a control. Announcing it whenever it is live gives a + // screen reader the route the ink withholds — so this is + // not a translation of the visual affordance so much as + // the honest version of it, and the gate is `has-reset && + // modified` rather than the hover the `Text` below adds. + accessible-role: button; + accessible-label: "Reset"; + accessible-enabled: root.has-reset && root.modified; + accessible-action-default => { + if (root.has-reset && root.modified) { + root.op-reset(); + } + } + reset-touch := TouchArea { // Sits after `header-touch` in the tree, so it takes // the press first and the section does not toggle out @@ -598,6 +702,20 @@ export component Panel inherits Rectangle { // token is for. export component Field inherits Rectangle { in-out property text; + /// What this entry is for, in words. + /// + /// The visible caption belongs to whatever row wraps the field — `TextRow` + /// draws one above, the launch screen draws one beside — and none of those + /// is a thing Slint associates with the entry on its own. So the name is + /// carried a second time, here, where the accessibility tree can attach it + /// to the control the user is actually typing into. + /// + /// That second copy is not redundant even where the caption renders. It is + /// the *only* copy where the caption does not: `TextRow`'s label draws + /// behind its own field on the settings page and has done since 0.9.0, so + /// a sighted user reading that page today has less to go on than a screen + /// reader does. + in property label; in property placeholder; /// Masks the entry, for a credential that should not be readable over the /// user's shoulder. The placeholder still shows while the field is empty. @@ -655,6 +773,13 @@ export component Field inherits Rectangle { input := TextInput { text <=> root.text; edited => { root.edited(self.text); } + // Slint gives a TextInput its role, its value, its enabled state and + // its set-value action for free; the name and the placeholder are the + // two it cannot guess. They go on the entry rather than on the box + // around it so there is one node in the tree and not a nameless + // rectangle wrapping a nameless input. + accessible-label: root.label; + accessible-placeholder-text: root.placeholder; color: Theme.ink; font-size: Theme.text; vertical-alignment: center; @@ -677,6 +802,12 @@ export component Field inherits Rectangle { x: Theme.gap; height: 100%; visible: input.text == ""; + // Drawn text, not content. The same words already reach the tree as + // the entry's `accessible-placeholder-text`, where a reader can + // announce them as a prompt rather than as a value the field holds — + // which is what a second text node beside an empty entry would look + // like. `lineedit-base.slint` in Slint's own widgets does exactly this. + accessible-role: none; } } @@ -693,6 +824,22 @@ export component Field inherits Rectangle { export component ProgressBar inherits Rectangle { in property fraction: 0; in property indeterminate: false; + /// What is progressing. A bar with no name announces as "progress + /// indicator, 40%", which says how far along an unnamed something is. + in property label; + + // An indeterminate bar reports no value at all rather than 0%. It has one + // — the sweep — but it is not a position, and a reader that announced 0% + // for a directory walk that is half done would be stating a falsehood in + // the one place the interface was careful not to (see the two modes + // above). Silence is the honest answer to "how far". + accessible-role: progress-indicator; + accessible-label: root.label; + accessible-value: root.indeterminate + ? "" + : Math.round(clamp(root.fraction, 0, 1) * 100) + "%"; + accessible-value-minimum: 0; + accessible-value-maximum: 100; height: 3px; background: Theme.rule; From 7409cb776710e65a1160e394da6317462c7e55e1 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 10:25:16 +0200 Subject: [PATCH 2/6] Make the launch screen translatable, and say how the rest follows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@tr(` appeared zero times in 14,482 lines of markup. Every string the user reads was a literal, so NFR-A11Y-1 was not partly done or done badly — there was nothing to extract and nothing a translator could have been given. The mechanism turns out to cost almost nothing, and the reason is worth stating because it decides the order of the work: a string written `@tr("Sign in")` is already correct in a build with no translation at all. Slint's `translate()` formats the original and hands it back when neither delivery path is active, so a converted string and a literal are the same string until someone writes a `.po`. That means the interface can be converted a screen at a time rather than in one 14,000-line commit that nobody can review, and every intermediate state is shippable. So the delivery half is wired and left inert. `build.rs` asks for bundled translations only once `lang//LC_MESSAGES/dr-ui.po` exists — the first catalogue anyone commits turns it on with no build-system change, and until then a checkout with no `lang/` builds exactly as it did. Bundling rather than the `gettext` feature because of Android: under ARCH §6.9's storage model there is no path a `.mo` could sit at that the app can reach, and no C library to link it against. The extraction command and the one flag that must not be passed to it are recorded in the module docs. The launch screen is converted whole: 32 calls covering every heading, button, caption and placeholder. It goes first because it is the screen a user cannot get past — an unreadable preferences page can be ignored, an unreadable sign-in cannot. Four kinds of literal are deliberately left alone and the file says which: the product name, example values whose shape is the message, `..`, and the path separator. Two things this leaves open, recorded rather than papered over. The headings carry their own capitals, because `PanelHeading` draws what it is handed — so a translator supplies "SERVEUR", not "Serveur", and styling in the string is a real cost now paid rather than a surprise later. And `@tr()` is markup only: the operation and parameter labels NFR-A11Y-1 names explicitly resolve in `labels.rs`, in Rust, because the core may not depend on a localisation library — those need a second mechanism, and it is not built. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/build.rs | 82 +++++++++++++++++++++++++++++++++++++- ui/dr-ui/ui/launch.slint | 86 +++++++++++++++++++++++++--------------- 2 files changed, 136 insertions(+), 32 deletions(-) diff --git a/ui/dr-ui/build.rs b/ui/dr-ui/build.rs index 8d6d2d7..642a9f3 100644 --- a/ui/dr-ui/build.rs +++ b/ui/dr-ui/build.rs @@ -12,6 +12,48 @@ //! `OUT_DIR` as an include path makes the generated file answer every //! existing `import { Theme } from "theme.slint"` unchanged. This only works //! while no `ui/theme.slint` exists to shadow it — see the guard below. +//! +//! # Translations (NFR-A11Y-1) +//! +//! A string written `@tr("Sign in")` in the markup is **already correct in a +//! build with no translation at all**: with neither the `gettext` nor the +//! `bundle-translations` path active, Slint's `translate()` formats the +//! original and returns it. That is the property that makes converting the +//! interface a string at a time possible rather than a flag day — the +//! alternative, wiring the machinery first and converting after, means every +//! intermediate commit ships an interface half of which cannot be translated +//! and none of which can be extracted. +//! +//! **Extracting.** `slint-tr-extractor` walks the `.slint` files and writes a +//! `.pot`: +//! +//! ```text +//! cargo install slint-tr-extractor +//! find ui/dr-ui/ui -name '*.slint' | xargs slint-tr-extractor -o dr-ui.pot +//! ``` +//! +//! Do **not** pass `--no-default-translation-context`. Slint's default +//! context is the enclosing component's name, which is what keeps the two +//! senses of a word like "or" apart when the same word is a conjunction on one +//! screen and a search operator on another — and the extractor and the +//! compiler have to agree about it or every lookup misses silently. +//! +//! **Delivering.** Two mechanisms exist and this one picks bundling: a `.po` +//! per language under `lang//LC_MESSAGES/dr-ui.po`, compiled into the +//! binary by the block in `main`. The alternative is Slint's `gettext` +//! feature, which reads `.mo` files off disk at runtime through the C gettext +//! library. Bundling wins here for one reason that outranks the rest: +//! **Android**, where there is no filesystem path a `.mo` could sit at that +//! the app can reach under ARCH §6.9's storage model, and no C library to +//! link. A build with no `lang/` directory takes neither path and keeps every +//! original string, which is exactly the state of this crate today. +//! +//! **What this does not reach.** `@tr()` is markup. The operation and +//! parameter labels NFR-A11Y-1 names explicitly are resolved in Rust, by +//! `labels.rs`, from the `LocalizedKey`s the core publishes — the core cannot +//! depend on a localisation library (ARCH §6.5a), which is the constraint that +//! put the catalogue in the UI crate in the first place. Translating those +//! needs a second mechanism on the Rust side, and it is not built. use std::collections::BTreeSet; use std::fmt::Write as _; @@ -21,6 +63,8 @@ use serde_norway::Value; const STYLE_YAML: &str = "style.yaml"; const GENERATED: &str = "theme.slint"; +/// Where a `.po` goes, relative to this crate: `lang//LC_MESSAGES/`. +const LANG_DIR: &str = "lang"; fn main() { println!("cargo:rerun-if-changed={STYLE_YAML}"); @@ -72,12 +116,48 @@ fn main() { // The failure is silent either way: a missing image loads as empty. // Embedding costs the size of ui/app-icon.png, the only asset this // reaches, since every UI glyph is a Path rather than a file. - let config = slint_build::CompilerConfiguration::new() + let mut config = slint_build::CompilerConfiguration::new() .with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")]) .embed_resources(slint_build::EmbedResourcesKind::EmbedFiles); + + // Bundling is asked for only once a translation exists to bundle. + // + // Enabling it unconditionally would make an empty `lang/` — or a + // missing one, which is every checkout today — into a build failure for + // everyone, in service of a feature nobody is yet using. Asking the + // directory instead means the mechanism is wired and inert: the first + // `lang/fr/LC_MESSAGES/dr-ui.po` someone commits turns it on with no + // build-system change, which is the point at which a translator can + // actually verify their work. + if let Some(lang) = translations(&manifest_dir) { + println!("cargo:rerun-if-changed={}", lang.display()); + config = config.with_bundled_translations(lang); + } + slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint"); } +/// The translation root, if any language has a catalogue in it. +/// +/// The domain is the crate name — slint-build takes it from `CARGO_PKG_NAME` +/// and this reads the same variable, so a rename does not leave the two +/// halves looking for different files. Checking for a `.po` rather than +/// merely for the directory is deliberate: an empty `lang/` left behind by a +/// half-finished translation would otherwise switch bundling on and hand every +/// string to a lookup with nothing behind it. +fn translations(manifest_dir: &Path) -> Option { + let root = manifest_dir.join(LANG_DIR); + let catalogue = format!("{}.po", env!("CARGO_PKG_NAME")); + let entries = std::fs::read_dir(&root).ok()?; + + for entry in entries.flatten() { + if entry.path().join("LC_MESSAGES").join(&catalogue).is_file() { + return Some(root); + } + } + None +} + /// The file handed to the Slint compiler. /// /// Normally `ui/app.slint` itself. Under `live-style` it is a generated diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index 1a05090..cae83d0 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -7,6 +7,30 @@ import { Check } from "controls.slint"; // Deliberately separate from AppWindow. It is the first thing a user sees // with no library configured, and the place they return to in order to sign // out or switch account (FR-NC-1, FR-NC-4). +// +// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first +// because it is the one a user cannot get past: an interface they cannot read +// is unusable here in a way it is not in a preferences page they could ignore. +// The mechanism, the extraction command and the reason a build with no +// translation behaves identically are in `build.rs`. +// +// Four kinds of string are deliberately *not* wrapped, and the distinction is +// worth stating because "wrap every literal" is the obvious rule and the wrong +// one: +// +// - **"DarkRoom".** A product name, the same in every language. Translating +// it invites a translator to answer the question, and there is no answer. +// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`, +// the app-password mask. These are shapes rather than sentences; a +// translated hostname would teach the wrong format. +// - **`".."`**, the row that leads out of a folder. A filesystem convention, +// not a word. +// - **`"/"`**, the path separator. +// +// The headings are wrapped and carry their own capitals — `PanelHeading` draws +// what it is given — so a translator supplies "SERVEUR" rather than "Serveur". +// That is a real cost of styling in the string, and it is recorded here rather +// than discovered by the first person to translate the screen. // This screen's buttons are form actions in a single stacked column, not // chrome beside a photograph — they are given the full `touch-target` height @@ -141,8 +165,8 @@ export component LaunchScreen inherits Rectangle { } Label { text: root.signed-in - ? "Connected" - : "Connect a Nextcloud account, or open a folder"; + ? @tr("Connected") + : @tr("Connect a Nextcloud account, or open a folder"); body: true; } } @@ -153,7 +177,7 @@ export component LaunchScreen inherits Rectangle { if !root.signed-in && root.login-url == "": VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "SERVER"; } + PanelHeading { text: @tr("SERVER"); } server-input := Field { text: root.server-url; @@ -162,20 +186,20 @@ export component LaunchScreen inherits Rectangle { } if !root.can-remember: Caption { - text: "No system keyring found — you will need to sign in each time."; + text: @tr("No system keyring found — you will need to sign in each time."); warn: true; wrap: word-wrap; } FormButton { - text: root.busy ? "Connecting…" : "Sign in"; + text: root.busy ? @tr("Connecting…") : @tr("Sign in"); primary: true; enabled: !root.busy && server-input.text != ""; clicked => { root.sign-in(server-input.text); } } Caption { - text: "Sign-in happens in your browser. DarkRoom never sees your password."; + text: @tr("Sign-in happens in your browser. DarkRoom never sees your password."); wrap: word-wrap; } @@ -193,7 +217,7 @@ export component LaunchScreen inherits Rectangle { background: Theme.rule; horizontal-stretch: 1; } - Caption { text: "or"; } + Caption { text: @tr("or"); } Rectangle { height: 1px; background: Theme.rule; @@ -201,13 +225,13 @@ export component LaunchScreen inherits Rectangle { } } - PanelHeading { text: "USERNAME"; } + PanelHeading { text: @tr("USERNAME"); } user-input := Field { text: ""; - placeholder: "your Nextcloud username"; + placeholder: @tr("your Nextcloud username"); } - PanelHeading { text: "APP PASSWORD"; } + PanelHeading { text: @tr("APP PASSWORD"); } pass-input := Field { text: ""; placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"; @@ -218,7 +242,7 @@ export component LaunchScreen inherits Rectangle { } FormButton { - text: root.busy ? "Connecting…" : "Connect directly"; + text: root.busy ? @tr("Connecting…") : @tr("Connect directly"); enabled: !root.busy && server-input.text != "" && user-input.text != "" @@ -233,7 +257,7 @@ export component LaunchScreen inherits Rectangle { } Caption { - text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own."; + text: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own."); wrap: word-wrap; } @@ -251,7 +275,7 @@ export component LaunchScreen inherits Rectangle { background: Theme.rule; horizontal-stretch: 1; } - Caption { text: "or"; } + Caption { text: @tr("or"); } Rectangle { height: 1px; background: Theme.rule; @@ -259,7 +283,7 @@ export component LaunchScreen inherits Rectangle { } } - PanelHeading { text: "FOLDER"; } + PanelHeading { text: @tr("FOLDER"); } folder-input := Field { text: root.folder-path; placeholder: "/home/you/Pictures"; @@ -267,13 +291,13 @@ export component LaunchScreen inherits Rectangle { } FormButton { - text: "Open folder"; + text: @tr("Open folder"); enabled: !root.busy && folder-input.text != ""; clicked => { root.use-folder(folder-input.text); } } Caption { - text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed."; + text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed."); wrap: word-wrap; } } @@ -282,7 +306,7 @@ export component LaunchScreen inherits Rectangle { if root.login-url != "": VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "APPROVE IN YOUR BROWSER"; } + PanelHeading { text: @tr("APPROVE IN YOUR BROWSER"); } Panel { Label { @@ -294,18 +318,18 @@ export component LaunchScreen inherits Rectangle { } FormButton { - text: "Copy link"; + text: @tr("Copy link"); clicked => { root.copy-login-url(); } } - Caption { text: "Waiting for approval…"; } + Caption { text: @tr("Waiting for approval…"); } } // --- folder picker --- if root.signed-in && root.browsing: VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "CHOOSE LIBRARY FOLDER"; } + PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); } // Current location, so it is always clear what // "Use this folder" would select. @@ -326,7 +350,7 @@ export component LaunchScreen inherits Rectangle { border-color: Theme.rule; if root.browse-loading: Caption { - text: "Loading…"; + text: @tr("Loading…"); horizontal-alignment: center; width: 100%; height: 100%; @@ -357,7 +381,7 @@ export component LaunchScreen inherits Rectangle { if !root.browse-loading && root.browse-entries.length == 0 && root.browse-path != "": Caption { - text: "No subfolders here"; + text: @tr("No subfolders here"); horizontal-alignment: center; width: 100%; height: 100%; @@ -367,12 +391,12 @@ export component LaunchScreen inherits Rectangle { HorizontalLayout { spacing: Theme.gap; FormButton { - text: "Cancel"; + text: @tr("Cancel"); horizontal-stretch: 1; clicked => { root.browse-cancel(); } } FormButton { - text: "Use this folder"; + text: @tr("Use this folder"); primary: true; horizontal-stretch: 1; clicked => { root.browse-confirm(); } @@ -384,22 +408,22 @@ export component LaunchScreen inherits Rectangle { if root.signed-in && !root.browsing: VerticalLayout { spacing: Theme.gap; - PanelHeading { text: "ACCOUNT"; } + PanelHeading { text: @tr("ACCOUNT"); } Value { text: root.account; } Rectangle { height: Theme.gap-sm; } - PanelHeading { text: "LIBRARY FOLDER"; } + PanelHeading { text: @tr("LIBRARY FOLDER"); } HorizontalLayout { spacing: Theme.gap; Value { - text: root.library-root == "" ? "(not chosen)" : root.library-root; + text: root.library-root == "" ? @tr("(not chosen)") : root.library-root; placeholder: root.library-root == ""; horizontal-stretch: 1; overflow: elide; } FormButton { - text: "Choose…"; + text: @tr("Choose…"); width: 110px; clicked => { root.choose-folder(); } } @@ -407,7 +431,7 @@ export component LaunchScreen inherits Rectangle { Rectangle { height: Theme.gap-sm; } - PanelHeading { text: "SCAN FOR"; } + PanelHeading { text: @tr("SCAN FOR"); } for label[i] in root.format-labels: Check { label: label; @@ -418,13 +442,13 @@ export component LaunchScreen inherits Rectangle { Rectangle { height: Theme.gap; } FormButton { - text: root.busy ? "Scanning…" : "Open library"; + text: root.busy ? @tr("Scanning…") : @tr("Open library"); primary: true; enabled: !root.busy && root.library-root != ""; clicked => { root.open-library(); } } FormButton { - text: "Sign out"; + text: @tr("Sign out"); clicked => { root.sign-out(); } } } From 80298403f7821e133351224d69a589161b8622b0 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 10:25:58 +0200 Subject: [PATCH 3/6] Name the launch screen's entries, and let its folder rows be pressed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A heading above a text field names it on screen and names nothing to the platform: Slint associates the two only if something says so, and nothing did. So the four entries a user signs in through — server, username, app password, folder — reached AT-SPI as unnamed boxes with a separate piece of static text floating above each. The word is now written twice, deliberately, and the second copy is the one attached to the control being typed into. FolderRow is the more consequential half. It is a Rectangle with a TouchArea over it, which is a button to the user and a decorated box to the platform — its label got through, because the `Value` inside is a Text, while the fact that the row could be entered at all did not. The folder picker was therefore readable and not navigable, which for the screen that chooses where the whole library lives is the difference between using the application and not. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/ui/launch.slint | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index cae83d0..7c18cdd 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -41,11 +41,21 @@ component FormButton inherits Button { } // One folder in the picker. The whole row is the target, not just the text. +// +// A `Rectangle` with a `TouchArea` over it is a button as far as the user is +// concerned and a decorated box as far as the platform is, so the name reaches +// a screen reader — the `Value` below is a `Text` — while the fact that it can +// be entered does not. Saying so is what makes the picker navigable rather +// than merely readable (NFR-A11Y-2). component FolderRow inherits Rectangle { in property label; in property is-parent: false; callback clicked(); + accessible-role: button; + accessible-label: root.label; + accessible-action-default => { root.clicked(); } + height: Theme.touch-target; background: touch.has-hover ? Theme.surface-raised : transparent; border-radius: 3px; @@ -180,6 +190,13 @@ export component LaunchScreen inherits Rectangle { PanelHeading { text: @tr("SERVER"); } server-input := Field { + // The `PanelHeading` above each of these entries names + // it on screen and names nothing at all as far as the + // accessibility tree is concerned — Slint associates a + // heading with the control below it only if something + // says so. `Field.label` is that something, so the word + // is written twice on purpose. + label: @tr("Server address"); text: root.server-url; placeholder: "https://cloud.example.com"; accepted(url) => { root.sign-in(url); } @@ -227,12 +244,14 @@ export component LaunchScreen inherits Rectangle { PanelHeading { text: @tr("USERNAME"); } user-input := Field { + label: @tr("Username"); text: ""; placeholder: @tr("your Nextcloud username"); } PanelHeading { text: @tr("APP PASSWORD"); } pass-input := Field { + label: @tr("App password"); text: ""; placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"; secret: true; @@ -285,6 +304,7 @@ export component LaunchScreen inherits Rectangle { PanelHeading { text: @tr("FOLDER"); } folder-input := Field { + label: @tr("Library folder"); text: root.folder-path; placeholder: "/home/you/Pictures"; accepted(path) => { root.use-folder(path); } From 46706fe6228cac961f5c86e84cec7ce6c9d03217 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 10:27:10 +0200 Subject: [PATCH 4/6] Let the tool rail be pressed, not only read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail is the develop view's primary navigation and reached the platform as four pieces of static text. The labels got through — a `Text` announces itself — so a screen reader could read "Photo, Crop, Local, Repair" and had no way to learn that any of them could be pressed, or which one was currently held. The one control that decides what a click on the photograph does was a caption. Each entry now declares itself a checkable button carrying the tool's own word, with the held state reported rather than left to the fill. Checkable is unconditional here, unlike `Button`'s: a rail entry is always a held-or-not state, so an unheld one should say "not pressed" rather than pass for an ordinary button. The action repeats the click handler's expression rather than calling it. A `TouchArea`'s `clicked` is raised by the pointer and cannot be raised from a binding, so the alternative is a function wrapping two lines — and the duplicated ternary sits four lines below the original where the two cannot drift out of sight of each other. Worth recording for the next control like this: `accessible-*` on an element inside a `for` does work, despite the accessibility pass skipping repeated elements. `process_repeater_components` runs first and moves the bindings into a real component whose root is not repeated; what the later pass skips is the empty placeholder left behind. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/ui/toolrail.slint | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/ui/dr-ui/ui/toolrail.slint b/ui/dr-ui/ui/toolrail.slint index da913f8..1753208 100644 --- a/ui/dr-ui/ui/toolrail.slint +++ b/ui/dr-ui/ui/toolrail.slint @@ -121,6 +121,26 @@ export component ToolRail inherits Rectangle { root.picked(entry.on ? ViewMode.photo : tool.mode); } + // **The word below is drawn; this is what makes it a control.** + // Without these the rail reaches a screen reader as four pieces of + // static text — the labels get through, because a `Text` announces + // itself, and nothing says any of them can be pressed. That is the + // develop view's primary navigation reduced to a caption. + // + // `checkable` unconditionally, unlike `Button`'s: a rail entry is + // always a held-or-not state, so an unheld one should say "not + // pressed" rather than pass for an ordinary button. The action + // repeats the click handler rather than calling it, because a + // `TouchArea`'s `clicked` is raised by the pointer and cannot be + // raised from here. + accessible-role: button; + accessible-label: tool.label; + accessible-checkable: true; + accessible-checked: entry.on; + accessible-action-default => { + root.picked(entry.on ? ViewMode.photo : tool.mode); + } + // The lit tile, and the only marker there is. Inset from the // rail's edges so the run of four reads as four things rather than // as one striped column. From bddd30fba236aefb375219d2fc3058015261d180 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 10:29:33 +0200 Subject: [PATCH 5/6] Measure the chrome's contrast, and say why font scaling is not a multiplier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NFR-A11Y-2 has three clauses and this branch closes one of them. The other two — WCAG AA contrast on non-canvas UI, and platform font scaling honoured without clipping — are now measured and reasoned about rather than left as two sentences in the requirements register that nobody had checked. Contrast is measured, not estimated. Every ink against every surface it is drawn on, by the WCAG 2 formula, and the result is narrower and more specific than "the palette is dark": `ink` and `warn-ink` pass everywhere, the inverted cases on the near-white fills are the best-contrasting text in the application at 18.6, and exactly two tokens fail. `ink-faint` reaches 4.5:1 on no surface at all — 3.97 at its best — and it is the ink for every caption, every section name and every count. `rule` reaches 3:1 on none either, and it is the border of every button, field and panel, so an unfilled secondary button is a 1.4:1 outline on a 1.2:1 ground. Neither is an oversight, which is why they belong here rather than in a bug list. They fall out of the palette's own argument: a bright surround biases how a photograph is judged and hue is banned outright, so all the signalling is luminance and the luminance is deliberately spent on the image. "Text you are meant to skip" and "text everyone can read" are in real tension. The fix is a re-derived ink scale and a stronger rule, both of which change how the application looks beside a photograph — a screenshot and an opinion, not a patch, and not something to do blind. Font scaling is the more interesting entry because the obvious fix is wrong. It is not a multiplier on the type sizes: the layout rests on constants that are not derived from them — control-height, touch-target, rail-entry-height, panel-width, and a dozen fixed heights written at their call sites — and scaling the type alone clips against every one, silently, because Slint elides rather than errors. The colour mixer is the sharpest case, thirty-six controls in a 360px column sized so a track and a swatch and a readout share a line. So the entry sets out the four pieces in dependency order, and notes that the first is nearly built already: `live-style` makes `build.rs` emit `in-out` theme tokens that Rust writes at startup, which is exactly the mechanism a scale factor needs. Both entries carry the falsifiable condition this document asks for, and the contrast table is the baseline a later measurement compares against. Co-Authored-By: Claude Opus 5 (1M context) --- docs/technical-debt.md | 146 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/docs/technical-debt.md b/docs/technical-debt.md index fb972b7..7a00b0b 100644 --- a/docs/technical-debt.md +++ b/docs/technical-debt.md @@ -297,6 +297,152 @@ millisecond for the `point` and `all` chains, and the codegen tests still pass b --- +## TD-6 — The quietest ink does not reach WCAG AA, and the rule does not reach 3:1 + +**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "non-canvas UI meets WCAG AA contrast". + +### What it does + +`style.yaml` sets three inks and four surfaces. Measured as WCAG 2 contrast ratios (sRGB relative +luminance, the standard formula), against the surfaces each ink is actually drawn on: + +| ink | on `ground` | on `surface` | on `surface-raised` | on `hover` | on `selected` | +|---|---|---|---|---|---| +| `ink` #EDEEF0 | 16.02 | 14.69 | 13.03 | 11.39 | 9.68 | +| `ink-dim` #9EA1A6 | 7.18 | 6.58 | 5.84 | 5.10 | **4.34** | +| `ink-faint` #71747A | **3.97** | **3.64** | **3.23** | **2.82** | **2.40** | +| `warn-ink` #C9A05A | 7.67 | 7.03 | 6.24 | 5.45 | 4.64 | +| `rule` #323438 | **1.49** | **1.37** | **1.21** | **1.06** | **1.11** | + +Every text size in the application is 11px, 13px, 17px or 24px, and WCAG's "large text" relief +begins at 18.66px bold or 24px regular — so all four of those thresholds are the normal-text one, +**4.5:1**, except the masthead. Bold entries fail it. + +The inverted cases pass and are worth stating so nobody re-measures them: `ground` on `active` +(#FFFFFF) is 18.60, on `active-dim` 11.20, on `active-pressed` 5.95, on `selected-ring` 13.02. The +near-white fills that `Button.primary`, `FilterChip.active` and the held tool-rail entry use are +the *best*-contrasting text in the interface, not the worst. + +So the failures are exactly two, and neither is where one would guess: + +- **`ink-faint` reaches 4.5:1 nowhere at all.** It is the ink for `Caption`, `PanelHeading`, + `Disclosure`, `Value`'s placeholder state and `FilterChip`'s count — every hint, every section + name, every "3 photographs" under a title. +- **`rule` reaches 3:1 nowhere.** WCAG 1.4.11 asks 3:1 of the boundary of a control the user must + perceive, and `rule` is the border of every `Button`, `Field`, `Panel`, `ChoiceChip` and + `IconButton`. An unfilled secondary button is a 1.4:1 outline on a 1.2:1 background. + +`ink-dim` on `selected` at 4.34 is a third case, marginal enough that a two-point lift fixes it. + +### Why + +Not an oversight — the direct consequence of the palette's own argument, which `style.yaml`'s +preamble makes at length and correctly. The chrome is deliberately quiet because a bright surround +biases how a photograph is judged, and hue is banned outright because an accent beside the image +shifts the perception of nearby colours. What is left to signal with is luminance, and the palette +spends its luminance range on the *photograph*, keeping the chrome inside a narrow band above the +ground. + +A narrow band is precisely what a contrast ratio measures. `ink-faint` exists to be skipped by the +reader who did not stop to look; that is a real design intent, and "text you are meant to skip" +and "text everyone can read" are in genuine tension rather than one being a mistake. + +### What it costs + +The photographer who cannot read a caption cannot read *any* caption, on any screen — this is one +token, so it fails everywhere at once. The hints under the settings switches say what a setting +costs, the section names say what a panel is, and the counts say how big a filter is. None of it is +decorative. + +### Paying it off + +Two token changes, and the second is the awkward one. + +`ink-faint` needs roughly #8A8D93 to clear 4.5:1 against `surface-raised`, the darkest surface it +is drawn on that matters — which puts it about where `ink-dim` sits today and collapses the +three-ink scale to two. So the real fix is to re-derive all three inks against the surfaces rather +than to nudge one: the scale wants to start higher and keep its steps, not compress. + +`rule` needs about #4A4D52 for 3:1 against `surface`. That is a visibly stronger line, and the +preamble's "instrument rather than absence" reasoning applies to it as much as to the greys — this +is a look change, not a number change, and it should be looked at rather than computed. + +Both are decisions about how the application appears next to a photograph, which is the one thing +this palette was designed around. They want a screenshot and an opinion, not a patch. + +**Done when:** every `Theme` ink reaches 4.5:1 against every surface it is drawn on, `rule` reaches +3:1 against `surface` and `surface-raised`, and a test recomputes those ratios from `style.yaml` so +the next palette edit cannot quietly undo it. The table above is the baseline to compare against. + +### Not in scope + +The histogram's `plot-*` inks (2.36 for `plot-luma` on `ground`) are drawn *on* the canvas, and +NFR-A11Y-2 scopes contrast to non-canvas UI. NFR-A11Y-3 covers what those need instead, and is +already met — the readouts name the channel in words. + +--- + +## TD-7 — Platform font scaling is not honoured + +**Breaks:** [requirements.md](requirements.md) NFR-A11Y-2 — "platform font scaling is honoured +without clipping". + +### What it does + +Nothing at all, which is the entry. Every type size is a constant in `style.yaml` — 11, 13, 17, 24 +— read as `Theme.text-sm` and friends at 65 call sites, and there is no multiplier anywhere between +the platform's font-size preference and those numbers. `scale_factor()` is read in `display_ui.rs` +and in `lib.rs`, but only to size the canvas in physical pixels for the render; it is display DPI, +which Slint already applies to logical lengths, and it is not the user's text-size setting. A +photographer who sets 130% text on GNOME or Android gets an application that ignores it. + +### Why + +Because honouring it is not a multiplier, and pretending it is would be worse than not doing it. + +The layout is built on constants that are not derived from the type size: `control-height` 28, +`touch-target` 44, `row-height` 26, `rail-entry-height` 54, `panel-width` 360, and a dozen fixed +heights written at their call sites — `ParamSlider`'s 46px, the folder picker's 220px box, the +readout column's 30px. Scaling the type alone clips against every one of them, silently, because +Slint elides rather than errors. `SwatchSlider` is the sharpest case: 12 hue bands × 3 channels in +a 360px column, sized so that a track and a swatch and a three-character readout fit on one line. + +And the failure is invisible to the person shipping it. The requirement's own phrase is "without +clipping", and clipping is exactly what a screenshot at 100% cannot show — the memory note on +verifying Slint changes exists because these files have a history of compiling, rendering and being +wrong. + +### What it costs + +The user for whom this matters most is not the screen-reader user the rest of this branch serves — +it is the one with usable but poor sight, who reads the interface and needs it larger. The +application is unusable to them at any setting, and there is no partial credit: text scaling is a +system-wide preference, so an app that ignores it is the one thing on the desktop that did. + +### Paying it off + +In the order the pieces depend on each other: + +1. **A scale token.** `Theme.text-sm` and the rest become `base × Theme.type-scale`, with the + scale an `in-out` property Rust writes at startup from the platform. `build.rs` already emits + `in-out` tokens under `live-style`, so the codegen half of this exists and is proven — that + feature is the mechanism, one line from being general. +2. **A source for the number.** GNOME publishes `text-scaling-factor` over the settings portal; + Android has `Configuration.fontScale` through JNI, beside the calls `lib.rs` already makes for + `ACTION_VIEW`. Both want a default of 1.0 and a sane clamp — 0.8 to 2.0 — because a user who has + set 300% for a phone launcher has not asked for a 72px slider readout. +3. **The constants that are not type.** Every fixed height a *label* sits inside has to follow the + scale; every touch target must not shrink and need not grow. That is the work, and it is where + the 46px and 220px literals get read one at a time. +4. **Evidence.** Screenshots at 1.0, 1.3 and 2.0 of the develop column, the settings page and the + colour mixer — the three densest layouts — because "without clipping" is a claim about the + worst case and nothing else will show it. + +**Done when:** the develop column, the settings page and the colour mixer render at a 2.0 scale +with no elided label and no touch target under 44 logical pixels. + +--- + ## Related, and deliberately not here The window-move rule, the grid's ordering index and the whole-library readout cache were *fixed* From 53b04dc561a0ec31f15b70055793127c7a109d00 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 10:31:01 +0200 Subject: [PATCH 6/6] Let the accessibility test see a callback that takes an argument MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sets()` recognised `name:` and `name =>` and not `name(arg) =>`, which is how Slint writes a callback handler with a parameter. There is exactly one of those among the properties asserted — `accessible-action-set-value`, the action a screen reader uses to type a number into a slider — so the test reported `SliderTrack` as missing the action it declares four lines above. A false alarm rather than a false pass, and the less dangerous of the two. It is still worth fixing rather than dropping the assertion: set-value is the action that makes a slider reachable without dragging, which is most of what the actions were added for. Co-Authored-By: Claude Opus 5 (1M context) --- ui/dr-ui/tests/ui_controls_are_accessible.rs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/ui/dr-ui/tests/ui_controls_are_accessible.rs b/ui/dr-ui/tests/ui_controls_are_accessible.rs index 15d7ada..44141bf 100644 --- a/ui/dr-ui/tests/ui_controls_are_accessible.rs +++ b/ui/dr-ui/tests/ui_controls_are_accessible.rs @@ -180,7 +180,13 @@ fn line_offsets(source: &str) -> impl Iterator { } /// Whether a block sets a property — the name at the start of a line, followed -/// by `:` for a property or `=>` for a callback. +/// by what Slint puts after a name it is binding to. +/// +/// Three forms, and the third is easy to forget: `name:` for a property, +/// `name =>` for a callback, and `name(arg) =>` for a callback that takes one. +/// `accessible-action-set-value` is the only member of the third group here and +/// leaving it out made this test report the slider as missing the very action +/// it declares. /// /// Anchored at the start of the trimmed line so that a *mention* in the prose /// above a component does not count. These files carry more comment than code, @@ -191,8 +197,10 @@ fn line_offsets(source: &str) -> impl Iterator { fn sets(block: &str, property: &str) -> bool { block.lines().any(|line| { let line = line.trim_start(); - line.strip_prefix(property) - .is_some_and(|rest| rest.starts_with(':') || rest.trim_start().starts_with("=>")) + line.strip_prefix(property).is_some_and(|rest| { + let rest = rest.trim_start(); + rest.starts_with(':') || rest.starts_with("=>") || rest.starts_with('(') + }) }) }