Merge: say what each control is, so a screen reader can use one
NFR-A11Y-2 went from five accessible-* declarations in the whole interface -- all five on the colour mixer's swatch row -- to seventy-three, on twelve shared components and four screens. The one that mattered is SliderTrack: the most-used control in the application, until now unnamed, and now carrying a label, a formatted readout and increment/decrement/set-value, so it is adjustable rather than merely readable. Fixing the shared components covered library.slint's twenty-seven buttons and eleven chips without editing that file at all. NFR-A11Y-1 is scaffolded and one screen of nine is converted -- 38 @tr() calls, about a tenth of the interface's strings. Slint's translate() returns the original when no bundle is active, so a converted string and a literal behave identically today and each remaining screen is an independent commit. Two accessibility defects are recorded rather than fixed, as TD-6 and TD-7 with measurements: ink-faint reaches 4.5:1 on no surface (3.97 at best) and rule reaches 3:1 on none. Fixing either re-derives the palette beside a photograph, which wants a screenshot and an opinion. Verified: fmt, clippy -D warnings, 563 tests including a new integration test that walks the markup and fails on an unnamed control. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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*
|
||||
|
||||
Reference in New Issue
Block a user