diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index dd6869a..8e5628e 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -704,7 +704,11 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .hist-close:hover { background: var(--bg-sunken); color: var(--text); } .hist-plot { display: block; width: 100%; height: 84px; } .hist-lum { fill: var(--text-dim); opacity: 0.38; } -.hist-ch { fill: none; stroke-width: 1.1; mix-blend-mode: screen; } +.hist-ch { fill: none; stroke-width: 1.1; } +/* Screen is how the three curves add up like light on a dark panel — but it is + measured against the backdrop, so on the light theme (a white plot) it + screens every channel straight to white and the histogram reads as empty. */ +[data-theme='dark'] .hist-ch { mix-blend-mode: screen; } .hist-r { stroke: #ff5a5a; } .hist-g { stroke: #4ade80; } .hist-b { stroke: #5aa2ff; } @@ -1392,4 +1396,46 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .col .chip { text-align: center; } .col-slider, .col-create { width: auto; } .stage { order: 0; flex: 1 1 auto; } + /* The row under the photo wears its icons on a phone. Six labelled buttons wrap + to three lines (measured 141px, 17% of an 844px screen) and take the height + the picture is trying to use; the labels are what wraps, so the labels go — + out of the paint, not out of the button, which keeps the accessible name it + always had. The hint at the end of the row follows them out: it is a + sentence, and a sentence was most of the wrapping. */ + .stage .toolbar { gap: 5px; } + .stage .toolbar .btn { + width: 34px; + height: 30px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 0; + } + .stage .toolbar .btn::before { font-size: 14px; line-height: 1; } + .stage .toolbar .btn .pro { display: none; } + .stage .toolbar .hint:not(.dims) { display: none; } + .stage .toolbar [data-key="clear-photo"]::before { content: '✕'; } + .stage .toolbar [data-key="open-photo"]::before { content: '↻'; } + .stage .toolbar [data-key="open-camera"]::before { content: '◉'; } + .stage .toolbar [data-key="save-original"]::before { content: '⤓'; } + .stage .toolbar [data-key="compare-toggle"]::before { content: '◐'; } + .stage .toolbar [data-key="hist-toggle"]::before { content: '▥'; } + /* The crop frame's four corner nodes. The square stays 16px — that is the size + a corner reads at — but a finger is ~44, so the touch target grows inward + from the node's own centre: the half that would grow outward is the photo's + own edge, which the layer clips anyway (measured before this: a node was + 9x16px to a touch, and a thumb a few pixels off it landed on the rect and + MOVED the frame instead of resizing it). The drag maths reads the pointer + against the photo, not against the node, so a larger target does not shift + the corner. */ + .crop-handle::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: 44px; + height: 44px; + transform: translate(-50%, -50%); + } } diff --git a/docker/frontend/src/ui/Histogram.tsx b/docker/frontend/src/ui/Histogram.tsx index e957082..bce5d29 100644 --- a/docker/frontend/src/ui/Histogram.tsx +++ b/docker/frontend/src/ui/Histogram.tsx @@ -178,11 +178,18 @@ export function autoWhiteBalance( // One channel across the full width of the ramp. `close` also draws the floor, // which is only wanted for the filled luminance curve. +// +// The height is log, not linear: a blown sky can put a third of a frame in one +// bin (measured on a real P1010256.JPG: 34% of pixels at bin 255), and under a +// linear scale that one bar owns 91% of the panel and every tone below it — +// the whole photo — reads as a flat line along the floor. log1p keeps an empty +// bin exactly on the floor while the tail stays readable. function curve(bins: number[], max: number, close: boolean): string { const step = W / (BINS - 1); + const top = Math.log1p(max); let d = ''; for (let i = 0; i < BINS; i++) { - d += `${i ? 'L' : 'M'}${(i * step).toFixed(1)} ${(H - (bins[i] / max) * H).toFixed(1)} `; + d += `${i ? 'L' : 'M'}${(i * step).toFixed(1)} ${(H - (Math.log1p(bins[i]) / top) * H).toFixed(1)} `; } return close ? `${d}L${W} ${H} L0 ${H} Z` : d; } @@ -258,9 +265,8 @@ export function Histogram({ url, onClose }: { url: string; onClose: () => void } if (e.currentTarget.hasPointerCapture?.(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId); }; - // One scale for all four curves, so their heights are comparable. ponytail: - // plain linear — a big sky flattens the rest; switch to a log scale here when - // that starts to hide the shadows. + // One scale for all four curves, so their heights are comparable — the log + // scale curve() draws on, so a blown sky cannot squash the rest. const max = Math.max(1, ...(data ? [...data.lum, ...data.r, ...data.g, ...data.b] : [1])); // The shape is parked in the photo's top-left corner as soon as it appears —