From bddd30fba236aefb375219d2fc3058015261d180 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 30 Aug 2026 10:29:33 +0200 Subject: [PATCH] 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*