Exposure, blacks and whites were set by eye. Nothing said a highlight had blown — the canvas shows white where a channel is at 250 and white where it is at 255, and the difference is the whole question. **Counted on the GPU, not on the readback.** There is a full frame sitting in CPU memory on every canvas update right now — `AdjustPass::read_output`, the bridge spike S1 removes — and walking it would have been thirty lines and no shader. FR-DSP-7 states the mechanism and not just the feature: "these derive from a GPU-side reduction into a small buffer. Per-frame CPU readback of image data is prohibited." A histogram founded on the bridge would be correct today and deleted by S1, and would meanwhile be the reason the bridge could not go. What crosses the bus here is 4104 bytes whatever the image size. The reduction tallies into workgroup memory first and merges once per workgroup. A photograph is not noise: a clear sky puts tens of thousands of adjacent pixels in one bin, and contending for that single global atomic serialises the dispatch. **On the settled frame only.** `render_now` already knows whether a gesture is still moving — `draft` is the flag `redraw` derives from `was_coalesced` — so the dispatch and its transfer happen once when the slider stops rather than on each of the forty frames a drag emits. Nothing is lost: a histogram flickering past under a finger is not a reading anyone takes. FR-DSP-7 requires exactly this, that it not extend the FR-DSP-3 frame budget. Luma is weighted in 8.8 fixed point — 54, 183, 19, summing to 256 exactly — rather than in floats. Not thrift: it makes the shader's arithmetic reproducible bit for bit, which is what lets the test below be an `assert_eq` against a CPU count rather than a tolerance. ARCH §6.13's line about integer state, applied where it happens to also be free. **What the numbers were checked against.** A flat frame must put all 4096 pixels in one bin and one only. A 256-wide ramp must occupy every level with exactly the same count, which is what catches an off-by-one in the quantisation — a `floor` where a rounding was needed shifts the whole photograph one bin left and looks like nothing at all. And a 101x37 frame of seeded pseudo-random pixels — deliberately not a multiple of the 16x16 workgroup, so the edge tiles run off the image — is compared slot for slot against a second, obvious CPU implementation. Exact equality, no tolerance. The CPU version is a deliberate reimplementation rather than shared code: the bugs worth catching here are ones shared code would commit identically on both sides. Above that, the presentation arithmetic is unit-tested headless, because it is where a wrong answer is invisible. A histogram of the wrong shape looks exactly as plausible as one of the right shape. So: 64 columns because it divides 256 and an uneven fold draws an even ramp as a comb; the peak excludes the end columns, or a night scene scaled against its own black spike is a flat line with no information in it; heights are clamped into the plot; and "0%" is kept distinct from "<0.1%" and from "—", since an indicator reading "clipped" over a figure reading "none" is a panel contradicting itself. Clipping counts a *pixel* with any channel at an extreme, not a channel. Any, because a blown red has no gradation left in it however much green and blue still hold — and it is the saturated highlight, the sunset and the red jersey, that clips first and recovers worst. Per pixel, because counting channels can report 200% of a frame clipped, and a percentage above 100 is a readout nobody trusts again. Two affordances for it, which NFR-A11Y-3 asks for: a bar standing at the end of the plot the tones are piling against, and a figure saying how much. Either alone reads. The panel sits directly under the capture metadata and above every control, because it is what the controls are judged against. It is hand-built rather than generated, and ARCH §4.3a is untroubled: a histogram is not an operation — no parameters, changes nothing, answers a question rather than asking one — and nothing in it reads a parameter out of a descriptor. Three plot colours and a neutral luma trace join the palette. That is the swatch's exception rather than a second one: a per-channel histogram has to say which channel, and no achromatic treatment distinguishes red from blue, so the hue is data exactly as the image beside it is. Held well back from full strength for the reason the theme preamble gives. The bounded, non-parking map wait moves out of `AdjustPass` into `readback::await_mapping`, shared with the histogram's transfer. Thirty lines of load-bearing reasoning about frozen interfaces and lost devices, and two copies of it would have drifted. The histogram describes the frame on the canvas, so it is in the output colour space FR-DSP-7 asks for, and when zoomed it describes the visible region — a photographer inspecting a highlight at 4x is asking about that highlight. A device that cannot build the reduction loses the histogram and keeps the photograph. Still to do for FR-DSP-7: the pixel colour readout under the cursor. 324 tests pass, clippy and fmt clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
198 lines
7.5 KiB
YAML
198 lines
7.5 KiB
YAML
# The source of truth for the UI's colour and length tokens.
|
||
#
|
||
# `build.rs` reads this file and generates `theme.slint` into OUT_DIR at
|
||
# compile time; nothing here is read at runtime unless the `live-style`
|
||
# feature is on. Edit this file, never the generated one.
|
||
#
|
||
# Leaf values only. A token that binds several values into one concept — a
|
||
# panel heading's colour *and* size *and* weight — is a Slint component, not
|
||
# a row in a YAML file (see widgets.slint).
|
||
#
|
||
# Structure:
|
||
# preamble — prose emitted at the top of the generated file
|
||
# colors: — name -> "#RRGGBB", or `alias:` naming another colour token
|
||
# lengths: — name -> pixels
|
||
# Any token may carry `note:` (a `//` comment above it) or `doc:` (a `///`
|
||
# doc comment, which Slint surfaces to editors).
|
||
#
|
||
# Two entries are dividers rather than tokens, because YAML discards the blank
|
||
# lines between map entries and the generated file's grouping has to survive:
|
||
# `section: <title>` — a headed run, optionally with its own `note:`
|
||
# `break: true` — a bare blank line between related tokens
|
||
|
||
preamble: |
|
||
Near-neutral dark palette, achromatic signalling.
|
||
|
||
Two commitments, both about not lying to the photographer.
|
||
|
||
**Dark ground.** A light UI surrounding an image biases how that image is
|
||
judged — the eye adapts to the brightest thing in view, and a white panel
|
||
makes a correctly-exposed photograph look dark.
|
||
|
||
**Near-neutral greys.** The earlier palette was a warm "darkroom safelight"
|
||
brown (ground #14120F, R twelve points above B). That is worse than it
|
||
sounds: simultaneous contrast pushes perception of the image *away* from the
|
||
surround, so a warm chrome makes a neutral photograph read cool, and the
|
||
photographer corrects toward warm to compensate. Every export drifts yellow.
|
||
It is the same reason a print viewing booth is neutral grey rather than
|
||
whatever colour the room happens to be.
|
||
|
||
These greys carry a 2–3 point blue lift rather than being flatly achromatic.
|
||
Pure R=G=B reads as dead to most eyes; a trace of cool reads as instrument
|
||
rather than absence, and biases far less than warmth because the eye is more
|
||
tolerant of a cool surround. The lift is small enough not to matter
|
||
perceptually and deliberate enough not to be mistaken for drift.
|
||
|
||
**No hue in the chrome.** Active and modified states are signalled by
|
||
brightness alone. An accent sitting beside the image competes with it for
|
||
attention and shifts the perception of nearby colours; a photo editor cannot
|
||
afford either. The one exception is `warn-ink` — a caution is genuinely a
|
||
different kind of thing from an active state, and hue is the fastest way to
|
||
say so.
|
||
|
||
colors:
|
||
ground: "#121314"
|
||
surface: "#1B1C1E"
|
||
surface-raised: "#252629"
|
||
rule: "#323438"
|
||
|
||
_inks: { break: true }
|
||
ink: "#EDEEF0"
|
||
ink-dim: "#9EA1A6"
|
||
ink-faint: "#71747A"
|
||
|
||
hover:
|
||
value: "#2E3034"
|
||
note: |
|
||
Interactive states for surfaces. Named rather than written inline so a
|
||
button, a section header and a list row cannot drift apart: hover lifts
|
||
toward the light, press sinks back past the resting surface so the
|
||
control reads as depressed rather than merely lit.
|
||
pressed: "#17181A"
|
||
|
||
_signalling:
|
||
section: signalling
|
||
note: |
|
||
Achromatic, so the separation has to come from luminance. These are
|
||
spaced further apart than a coloured palette would need: with hue
|
||
unavailable, a two-step brightness difference is invisible, and a
|
||
modified marker that cannot be spotted at a glance is not a marker.
|
||
|
||
active:
|
||
value: "#FFFFFF"
|
||
doc: |
|
||
An active or engaged control: a slider fill, a curve line, a checked
|
||
box. Brighter than `ink` so it reads as lit rather than merely present.
|
||
active-dim:
|
||
value: "#C6C9CE"
|
||
doc: Active, at rest — a filled control that is not under the pointer.
|
||
active-pressed:
|
||
value: "#8E9298"
|
||
doc: Active, pressed. Sinks rather than lifts, matching `pressed`.
|
||
|
||
modified:
|
||
alias: active
|
||
note: |
|
||
"This differs from its default." Deliberately the brightest thing in the
|
||
chrome: it is the one piece of state the photographer scans for, and
|
||
with no hue to carry it, brightness is all there is.
|
||
|
||
selected:
|
||
value: "#383B40"
|
||
doc: |
|
||
A selected grid cell. A lifted neutral rather than a tint — distinct
|
||
from `hover` because a multi-selection must stay legible after the
|
||
pointer has moved on, which is the whole point of selecting several
|
||
before dragging them.
|
||
selected-ring:
|
||
value: "#D5D8DD"
|
||
doc: |
|
||
The ring around a selected cell. Brighter than the fill so selection
|
||
survives against a pale thumbnail, where the fill alone would vanish.
|
||
|
||
warn-ink:
|
||
value: "#C9A05A"
|
||
note: |
|
||
Semantic, and the only hue in the palette. A caution is not an active
|
||
state, and it is worth the one exception to say that instantly. Muted
|
||
rather than saturated so it does not shift perception of a nearby image.
|
||
|
||
_plot:
|
||
section: plot series
|
||
note: |
|
||
The histogram's four traces (FR-DSP-7). Hue here is the same exception the
|
||
swatch takes and not a second one: a per-channel histogram has to say
|
||
*which channel*, and no achromatic treatment can distinguish red from blue
|
||
— so the colour is data, exactly as the image beside it is.
|
||
|
||
Held well back from full strength, and darker than `ink`, for the reason
|
||
the theme preamble gives: three saturated traces sitting a few centimetres
|
||
from the photograph would compete with it and shift how its colours read.
|
||
These are legible against `ground` at a glance and no louder than that.
|
||
|
||
plot-red: "#C4626A"
|
||
plot-green: "#6BA867"
|
||
plot-blue: "#5F8CCB"
|
||
|
||
plot-luma:
|
||
value: "#4E5257"
|
||
doc: |
|
||
The luminance trace, drawn filled and behind the three channels. Neutral
|
||
because luminance is not a channel — it is the axis exposure is read off,
|
||
and a hue would imply it were one series among four rather than the one
|
||
the other three are decomposing.
|
||
|
||
lengths:
|
||
_gaps: { break: true }
|
||
gap-sm: 6
|
||
gap: 12
|
||
gap-lg: 20
|
||
|
||
_text: { break: true }
|
||
text-sm: 11
|
||
text: 13
|
||
text-lg: 17
|
||
text-xl: 24
|
||
|
||
radius-sm:
|
||
value: 3
|
||
note: |
|
||
Corner radii. Two steps only: `radius-sm` for things that sit inside
|
||
other things, `radius` for the controls themselves.
|
||
radius: 4
|
||
|
||
touch-target:
|
||
value: 44
|
||
note: "FR-UI-3: minimum 44pt hit target under touch."
|
||
|
||
row-height:
|
||
value: 26
|
||
note: |
|
||
One row of the collections tree, and one level of nesting. Both are
|
||
tokens because a tree's indentation has to stay proportional to its row
|
||
height; hard-coding either makes the hierarchy read wrong when the other
|
||
changes.
|
||
indent: 14
|
||
|
||
control-height:
|
||
value: 28
|
||
note: |
|
||
The drawn height of a button or section header. Deliberately shorter
|
||
than `touch-target` — chrome this tall in every row would crowd the
|
||
photograph — so controls grow their TouchArea past their own bounds to
|
||
meet FR-UI-3 rather than growing their ink.
|
||
control-min-width:
|
||
value: 88
|
||
doc: |
|
||
Floor on button width, so a one-word label is still a comfortable
|
||
target and a row of buttons has an even rhythm.
|
||
|
||
swatch:
|
||
value: 12
|
||
note: |
|
||
The colour square identifying which hue band a row edits. Small on
|
||
purpose: a swatch is data, not chrome — the one sanctioned exception to
|
||
an achromatic palette — and it earns that exception by identifying the
|
||
row without competing with the photograph beside it. Big enough to name
|
||
a hue at a glance, and no bigger.
|