docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
452 lines
23 KiB
Markdown
452 lines
23 KiB
Markdown
# DarkRoom — Technical debt
|
||
|
||
**Status:** Living document · first written 2026-08-26
|
||
**Companion to:** [architecture.md](architecture.md)
|
||
|
||
Deliberate compromises: things the code does knowing they are wrong, because the alternative was
|
||
worse at the time. Each entry says what the debt is, what it cost to take on, what it would take to
|
||
pay off, and how you would know it had been paid.
|
||
|
||
Not a bug list. A bug is something nobody chose. Everything here was chosen, and the point of
|
||
writing it down is that the reasoning outlives whoever chose it — so the next person can tell a
|
||
constraint from an accident, and does not "fix" something load-bearing or preserve something that
|
||
has quietly stopped being necessary.
|
||
|
||
---
|
||
|
||
## TD-1 — The Android develop view reads pixels back through the CPU
|
||
|
||
**Breaks:** [architecture.md §12 / 6.1](architecture.md) — GPU results never round-trip through the
|
||
CPU — and AC-8, on Android only. Desktop is unaffected and keeps the zero-copy path.
|
||
|
||
### What it does
|
||
|
||
`DevelopSession::render` on Android runs the compute passes on the GPU as usual, then calls
|
||
`AdjustPass::export_pixels` and hands the frame to Slint as a `SharedPixelBuffer`. That is exactly
|
||
the GPU→CPU→GPU transfer §6.1 exists to forbid, and it is on the frame path.
|
||
|
||
### Why
|
||
|
||
Zero-copy needs Slint to draw with wgpu. On Android that means wgpu's Vulkan swapchain, which
|
||
hardcodes `preTransform = VK_SURFACE_TRANSFORM_IDENTITY_BIT_KHR`
|
||
([gfx-rs/wgpu#3345](https://github.com/gfx-rs/wgpu/issues/3345)) — wgpu-hal says so in a comment
|
||
beside the line.
|
||
|
||
On a tablet whose panel is mounted landscape, a portrait window then hands Android an unrotated
|
||
buffer, every present returns `VK_SUBOPTIMAL_KHR`, and frames arrive torn. Measured on the device,
|
||
same build, only the tablet rotated:
|
||
|
||
| orientation | `bufferTransform` | composition | result |
|
||
|---|---|---|---|
|
||
| landscape | `ROT_180` | `DEVICE (2)` | clean |
|
||
| portrait | `ROT_270` | `CLIENT (1)` | torn |
|
||
|
||
Setting `preTransform` is not a fix available to us: the field is a *promise* that the content is
|
||
already rotated, so honouring it needs the renderer to rotate what it draws, which wgpu cannot do
|
||
on Skia's behalf.
|
||
|
||
So the choice was never fast-develop against slow-develop. It was a develop view that costs a
|
||
readback against a grid that tears in the orientation a tablet is mostly held in.
|
||
|
||
### What it costs
|
||
|
||
Less than §6.1's headline numbers, because `render` fits the pass to the canvas before it runs — the
|
||
readback is at viewport resolution, not sensor resolution. The 7.43 ms at 4K in §12 is the ceiling,
|
||
not the bill. **It has not been measured on the device**, which is the first thing to do if the
|
||
develop view feels heavy on the tablet; do not assume this is the cause without a number.
|
||
|
||
### And a second transfer, while focus peaking is on
|
||
|
||
Added 2026-08-29 with FR-CULL-3. The focus-peaking overlay is a compute pass writing its own
|
||
`Rgba8Unorm` texture, which on desktop reaches the compositor with no copy — but on Android there is
|
||
no more a path for *that* texture than for the frame it belongs to, and an overlay that stayed on
|
||
the device while the picture underneath it did not would simply never be seen. So
|
||
`FocusPeakPass::read_overlay` follows the frame back through memory, and the Android frame path
|
||
carries **two** full-resolution `copy_texture_to_buffer` transfers instead of one.
|
||
|
||
This is recorded under TD-1 rather than as its own entry because it is not an independent choice.
|
||
It exists only because TD-1 exists, it is bounded by the same thing — `render` fits the pass to the
|
||
canvas, so both transfers are at viewport resolution — and TD-1's "Done when" already covers it:
|
||
whichever of the three fixes above lands removes the readback for the frame and the overlay
|
||
together, because both are the same missing capability.
|
||
|
||
Two things worth saying plainly. The doubling is **reasoned, not measured on the device** — the same
|
||
gap TD-1 admits about its own cost, and the reason neither number should be quoted as a measurement.
|
||
And it is paid only while the photographer has the overlay switched on: `DevelopSession::focus_overlay`
|
||
returns on its first line when peaking is off, so with it off there is no dispatch and no transfer,
|
||
and the Android frame path is exactly what it was before this feature existed.
|
||
|
||
### Paying it off
|
||
|
||
Any one of these removes it:
|
||
|
||
- wgpu implements pre-rotation (#3345), and Android goes back on `unstable-wgpu-29`.
|
||
- Slint's Skia Vulkan surface handles `preTransform` and Android uses that instead of OpenGL.
|
||
- Skia over OpenGL grows a way to sample an external texture that wgpu can write.
|
||
|
||
**Done when:** `ui/dr-ui/Cargo.toml` no longer scopes `renderer-femtovg-wgpu` and
|
||
`unstable-wgpu-29` to non-Android, the `#[cfg(target_os = "android")]` arm of
|
||
`DevelopSession::render` is gone, and the tablet is clean in portrait.
|
||
|
||
---
|
||
|
||
## TD-2 — Thumbnails are fetched one at a time
|
||
|
||
**Where:** `library::spawn_thumbnails` — the `for req in to_fetch` loop.
|
||
|
||
### What it does
|
||
|
||
The interactive thumbnail batch fetches serially: one image at a time, and two HTTP round trips
|
||
each (a header read, then the preview's byte range). A window of a few hundred cells is that many
|
||
sequential round trips against the server.
|
||
|
||
### Why it is debt rather than a bug
|
||
|
||
It is correct, and it was fast enough when a window was one screenful. It is the *ordering* that
|
||
kept it survivable: since `fetch_rank`, on-screen cells are requested first, so the cells a person
|
||
is looking at arrive first even though the queue as a whole is slow.
|
||
|
||
Portrait makes it worse by construction — a narrow window means smaller cells, more rows, and two
|
||
to three times as many cells on screen at once, all of them ahead of the ones below in a queue that
|
||
never runs more than one request.
|
||
|
||
### Paying it off
|
||
|
||
`spawn_thumbnail_sweep` already has the pattern: `SWEEP_LANES` disjoint lanes over a chunk, joined,
|
||
with the store written on the one thread that owns it. Striping a *priority-ordered* chunk across
|
||
lanes keeps `fetch_rank`'s ordering while running several requests at once.
|
||
|
||
Not done yet because it multiplies concurrent requests against the user's Nextcloud during a
|
||
scroll, and that is a behaviour change worth deciding on deliberately rather than inheriting from a
|
||
performance fix.
|
||
|
||
**Done when:** the interactive batch runs on more than one lane, priority order is preserved
|
||
across the lanes, and a slow server still cannot stall the visible cells behind offscreen ones.
|
||
|
||
---
|
||
|
||
## TD-3 — The thumbnail drain applies an unbounded batch on the UI thread
|
||
|
||
**Where:** `library_ui::drain_thumbnails` — the `loop` inside the timer callback.
|
||
|
||
### What it does
|
||
|
||
Every message queued when the timer fires is applied in that one callback, with no ceiling. On a
|
||
library whose thumbnails are already in the store, the worker delivers a whole window at once, so a
|
||
single callback can do hundreds of `to_slint_image` calls back to back — each an allocation and a
|
||
full RGBA copy — while the grid is mid-flick.
|
||
|
||
The copy cannot move off the UI thread: `slint::SharedPixelBuffer` is not `Send`, so decoded bytes
|
||
can only become an `Image` on the thread that draws. Only the *amount done per wake* is ours to
|
||
choose, and right now it is "all of it".
|
||
|
||
### Cost
|
||
|
||
Measured with a temporary probe, **debug build**, so treat the shape rather than the size:
|
||
|
||
| class | per thumbnail | × a 280-cell window |
|
||
|---|---|---|
|
||
| grid, 256 px | 1.93 ms | 539 ms |
|
||
| large, 512 px | 7.78 ms | 2.18 s |
|
||
|
||
A release measurement was started and never completed — do not quote these as release figures.
|
||
|
||
### Paying it off
|
||
|
||
A time budget per wake and a shorter interval: apply for a few milliseconds, return without
|
||
stopping the timer, and finish on the next tick. A batch then lands in frame-sized slices rather
|
||
than one lump between two frames. Draft written and discarded during the investigation; it is a
|
||
small change.
|
||
|
||
**Done when:** one wake of the drain cannot exceed a frame, and a fully-cached window still fills
|
||
in well under a second.
|
||
|
||
---
|
||
|
||
## TD-4 — The local-contrast base is computed at full render resolution ✅ PAID OFF
|
||
|
||
**Where:** `dr_pipeline::ops::local_contrast::LocalContrast::passes` — the `base` and `combine`
|
||
passes, and the stage that dispatches them, `dr_pipeline::detail`.
|
||
|
||
Breaks **FR-DSP-3** at large viewports. Measured, and the numbers are in
|
||
[frame-budget.md](frame-budget.md) §M3.
|
||
|
||
### What it does
|
||
|
||
Clarity's Gaussian σ is 1.2% of the frame's shorter edge, truncated at 2σ, so its kernel radius is
|
||
a property of the *viewport*: 29 px at 1920 × 1200, 38 px at 2560 × 1600, **52 px at 4K**. The two
|
||
separable passes therefore run 105 taps each over 8.3 M pixels at 4K, which is 1.7 billion texture
|
||
reads for one control.
|
||
|
||
| viewport | radius | clarity alone, p99 |
|
||
|---|---:|---:|
|
||
| 1920 × 1200 | 29 | 5.99 ms |
|
||
| 2560 × 1600 | 38 | 12.44 ms |
|
||
| 3840 × 2160 | 52 | **33.89 ms** |
|
||
|
||
RTX 3050 laptop, `examples/frame_budget`, fused dispatch reused so this is the convolutions alone.
|
||
Clarity is 97% of the cost of all four neighbourhood operations together at every size.
|
||
|
||
For scale: the entire fused chain — every point operation active, film stock included — costs
|
||
4.5 ms at the same 4K viewport. **A single slider is seven times the rest of the pipeline.**
|
||
|
||
### Why
|
||
|
||
Because the stage cannot do otherwise yet. `dr_pipeline::detail` dispatches every pass at the
|
||
render size; there is no way to express "read this target and write a smaller one". The module's own
|
||
documentation has said so since it was written:
|
||
|
||
> The right optimisation is a base computed at reduced resolution, which needs a detail stage that
|
||
> can write a smaller target than it reads; that is a change to `crate::detail`, not to this file.
|
||
|
||
It was the right call to ship the correct answer slowly rather than a fast approximation nobody had
|
||
checked — the halo behaviour is the hard part of this operation and it is tested.
|
||
|
||
### Not a tiling problem
|
||
|
||
Worth saying because ARCH §5.3 offers a tile cache and this is the stage that looks like it wants
|
||
one. It does not: a tiled convolution reads a halo per tile, so at a 52-pixel radius, 256-pixel
|
||
tiles would read (256 + 104)² instead of 256² — very nearly **twice** the taps.
|
||
[display-and-extension.md](display-and-extension.md) §2's decision rule was resolved on this
|
||
evidence; see [frame-budget.md](frame-budget.md).
|
||
|
||
### Paying it off
|
||
|
||
A detail pass that declares an output scale, so the base can be computed at a quarter resolution and
|
||
sampled back up in `combine`. A quarter-resolution base is 1/16 the pixels at 1/4 the radius —
|
||
about **1/64 of the work** — and is visually identical, because a base at σ = 26 px holds no content
|
||
above the quarter-resolution Nyquist to lose. Texture's σ is a decade finer and must stay at full
|
||
resolution; the scale therefore belongs on the `DetailPass`, not on the stage.
|
||
|
||
**Done when:** clarity at 100% is inside the frame budget at 3840 × 2160, the halo tests in
|
||
`tests/local_contrast.rs` still pass unchanged, and `examples/frame_budget`'s M3 table in
|
||
[frame-budget.md](frame-budget.md) has been rerun and committed.
|
||
|
||
### Paid off
|
||
|
||
A `DetailPass` now declares `output_scale`, and clarity's base is computed on a grid a quarter the
|
||
size on each axis. Measured before and after on the same machine, same adapter, same build profile,
|
||
with only the change between them — see [frame-budget.md](frame-budget.md) §"The reduced base,
|
||
measured":
|
||
|
||
| viewport, fit | before p50 | after p50 | | before p99 | after p99 |
|
||
|---|---:|---:|---:|---:|---:|
|
||
| 1920 × 1200 | 2.30 ms | 1.56 ms | 1.5× | 3.94 ms | 1.97 ms |
|
||
| 2560 × 1600 | 4.41 ms | 1.95 ms | 2.3× | 10.40 ms | 2.37 ms |
|
||
| 3840 × 2160 | **10.94 ms** | **3.88 ms** | **2.8×** | 25.05 ms | 4.17 ms |
|
||
|
||
**Quote the p50 column.** The baseline run's p99 figures are contaminated — its `fit` rows spread
|
||
2.3× between median and 99th percentile where the after run spreads 1.1×, and
|
||
[frame-budget.md](frame-budget.md)'s own independent measurement of the same baseline on the same
|
||
card reports 4.67 ms p99 at 2560 × 1600 against the 10.40 ms here. The p99 improvement is real and
|
||
larger than 2.8×; this run cannot say by how much.
|
||
|
||
Clarity is no longer the stage that misses the budget, and no longer dominates the neighbourhood
|
||
stage: at 4K it is 4.17 ms against 4.61 ms for all four neighbourhood operations together, where it
|
||
was 97% of that total at every size. That comparison is within one run, so the contention does not
|
||
touch it.
|
||
|
||
**Two things worth recording, because neither is visible in the table.**
|
||
|
||
The declared halo is now quantised to multiples of `output_scale`. The kernel truncates at 2σ and
|
||
that rounding now happens on the reduced grid, so 1920 × 1200 reports 28 render pixels where it
|
||
reported 29, and 2560 × 1600 reports 40 where it reported 38. At 2σ the Gaussian is already down to
|
||
`e⁻²` of its peak, and the cross-form test holds the difference to 0.03 stops of peak excursion and
|
||
2% of frame reach — but it is a real change in reach, not a pure speed-up, and a tile scheduler
|
||
would see it.
|
||
|
||
The measurement was taken on an AMD RX 5700 XT, not the RTX 3050 the M1/M2/M3 tables above were
|
||
measured on, so the *absolute* figures are not comparable with those. The before/after is, because
|
||
both halves of it were measured on the same card minutes apart.
|
||
|
||
---
|
||
|
||
## TD-5 — The fused shader is reassembled from strings on every frame
|
||
|
||
**Where:** `dr_pipeline::operation::compose_full`, called from `DevelopSession::render`.
|
||
|
||
### What it does
|
||
|
||
`EditGraph::compose` walks the active operations and formats a WGSL source string, per frame, on
|
||
the UI thread. On a full chain that is **2.8–5.2 ms** — at 1920 × 1200 it is larger than the entire
|
||
fused dispatch it precedes, and on the chains that also carry a detail stage it is a third of what
|
||
is left of the 16 ms budget after the GPU has taken its share. It does not vary with resolution,
|
||
because it is not pixel work.
|
||
|
||
### Why
|
||
|
||
Because it was free until the chain got long. Composition was written when an edit was two or three
|
||
operations, and the cost is roughly linear in generated source: the colour mixer emits twelve hue bands
|
||
and the tone curve emits a spline evaluator, so a full chain is a large string built from scratch
|
||
sixty times a second.
|
||
|
||
### Paying it off
|
||
|
||
The generated source depends only on the *structure* of the graph — which is precisely what
|
||
`ComposedShader::structure_hash` already identifies, and precisely what does not change while a
|
||
slider is being dragged. `AdjustPass` relies on that already: it caches compiled pipelines against
|
||
that hash and does not recompile during a drag. Caching the source string against the same hash and
|
||
rebuilding only the uniforms — a handful of floats per operation — takes this to approximately
|
||
nothing on the path that needs it most.
|
||
|
||
The care needed is in what the hash covers. It deliberately excludes parameter *magnitudes*, so a
|
||
cache keyed on it is sound for the source and would be wrong for anything else in `ComposedShader`.
|
||
|
||
**Done when:** the `shader` column of [frame-budget.md](frame-budget.md)'s M1 table is under a
|
||
millisecond for the `point` and `all` chains, and the codegen tests still pass byte for byte.
|
||
|
||
---
|
||
|
||
## 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*
|
||
rather than deferred — see the commits around `6d6ef8d`. They are mentioned only so that a reader
|
||
looking for "why was the grid slow" finds the answer in the code and its comments rather than
|
||
assuming it is still outstanding.
|