Three things were wrong on a 390px phone, and all three cost the photo its
screen. The toolbar's six labelled buttons wrapped to three lines (141px of an
844px screen, 17% of it) and now sit on one 30px row of icons; the histogram
read as an empty white plot; and a crop corner was a 9x16px target to a touch,
so a thumb a few pixels off it moved the frame instead of resizing it.
The toolbar keeps its labels in the DOM and only takes them out of the paint —
font-size 0 on the button, the glyph on ::before — so every button keeps the
accessible name it always had, and the two toggles still report their own
state. Verify: measured on a 390x844 viewport with a real P1010256.JPG, the row
is 30px tall, one row, buttons 34x30, `fontSize` 0px, the labels still in the
DOM as text, and hist-toggle still flips aria-pressed both ways.
The histogram was two separate faults, and the first one was invisible to the
DOM. Measured: after upload or after a close/reopen the plot is an empty frame
for ~100ms (4 paths land at 104ms / 126ms) — that is the mount, not a bug. But
the SVG then carried four full-length paths whose own map was flat: the lum
curve sat 0.9 of the panel high on ONE bin and every other bin measured 0.066
or less, so the photo read as a line along the floor. That is real: the
sampled frame has 34% of its pixels on bin 255, and under a linear scale a
blown sky owns the whole panel. The scale is log1p now — an empty bin still
sits exactly on the floor — and the bins go from 1 bin above half to 226.
The second fault was the paint: `.hist-ch` screens its three curves, which is
how light adds up on a dark panel, and it is measured against the backdrop —
so on the light theme (a white plot) it screened every channel straight to
white. Screen is now gated to `[data-theme='dark']`; measured on the light
theme, the plot went from 52 red / 39 green pixels to 931 / 1143 with the grey
fill under them.
The crop corner keeps its 16px square, which is the size a corner reads at,
and grows only its touch target: 44px centred on the node, so the outward half
is clipped by the photo's own layer and the target never overlaps the rect it
would otherwise hand the drag to. Measured: 23x44px reachable around each
node, and a drag from the bottom-right corner's centre still resizes (w 358 ->
297, h 201 -> 167) without moving the frame.
Verified: `npx tsc --noEmit` clean, `npm run build` clean, and every number
above read back off the built bundle in a 390x844 mobile context.
Co-authored-by: PenguinHarness <noreply@penguin.local>
The AUTO chip measured the photo and wrote one knob, EV. It now writes the four
the measurement actually names, off the same binned ramp (ui/Histogram.tsx),
which is why it is one chip and not four: the means it needs are all in the
histogram the exposure answer already reads.
EV the mean luma, unchanged
HIGHLIGHT the top 1% (p99 > 0.9 pulls back), to -5 of the ruler at most
SHADOW the bottom 1% (p01 < 0.02 opens up), to +5
TEMPERATURE/TINT the gain that puts the three channel means on each other,
green as the anchor: gray-world on linearised means, then the
closest of 76 temperatures x 21 tints under the renderer's own
kelvinToRGB, so the pair cannot drift from what the ruler applies.
The ends rather than the average is what keeps a small blown window from
dragging the whole frame: a specular in the corner wants HIGHLIGHT, not a
flatter picture everywhere. Both ends stop at half the ruler, so the frame is
corrected and a hand can still finish the move; the knobs then report the
numbers AUTO chose, the way the EV knob does.
Each reading is a pure function of the ramp, so pressing AUTO twice lands on the
same recipe by construction, and a frame with a dead channel leaves the WB ruler
where it is rather than inventing a cast. ponytail: one linear ramp per end and
no scene analysis; add a curve, or weight by how much of the frame is clipped,
when AUTO starts overshooting a scene with a genuine specular in it.
scripts/auto-tone-check.mjs holds the three readings: the percentile walk, the
thresholds that leave a knob alone, and the scan landing back on the gain it was
asked for.
A stop is a multiplier on light, so EXPOSURE and EV stop living in the sRGB
colour matrix and get a linear-light pass of their own (EXPOSURE_SKSL:
linearise, `C * 2^EV`, re-encode). The matrix keeps CONTRAST: a gain on encoded
values is what made +1 EV land at x1.5 instead of x2. Measured on the neutral
PROVIA sim: EV +1 = x2.011, EV +2 = x3.999, still unclipped at 239.
The pass sits between the matrix and the tone shader, and the tone / cinema /
curve / glow / halation children all sample through it, so HIGHLIGHT finally
sees the value exposure produced instead of the one before it. Recovery keeps
`L + strength * mask * (1 - L)` over `smoothstep(0.50,1.00,luma)`, and the
colour comes back as `color * (luma_new / luma)`: a blown white stays white
(255 -> 255 at -10, 255 at +10), a 0.8 grey loses 33 luma, the midtones beside
it do not move.
AUTO is the histogram the LIGHT tab already draws: weighted mean luminance
(guard 0.001), target 0.48, `log2(0.48 / avg)` clamped to +-2.5 EV, handed to
the same knob. A 0.251 grey asks for EV 0.9 and lands at mean 83.0 against the
83.3 predicted, idempotent on a second press. A stock's own bias rides the same
pass (`SIM_EXPOSURE_BIAS_EV`, VIVID +0.25 EV) and cancels against the knob, so
-1 EXPOSURE on VIVID returns the CLASSIC rendering (measured 0.4149 vs 0.4177).
ponytail: the phone app's `src/utils/colorUtils.ts` keeps the old math, so the
two copies have to move together; recipes saved before this commit (EXPOSURE 2,
HIGHLIGHT +-1) render under the new stop semantics.
Verified on the rebuilt container (BASE=http://localhost:8090):
- web-exposure-probe.cjs 20 PASS / 0 FAIL (neutral 128 -> 128, EV +1 ratio
2.011, EV +2 ratio 3.999, EXPOSURE +10 ratio 5.62 / -10 ratio 0.172, AUTO
EV 0.9, HIGHLIGHT -10 on a 204 grey 204 -> 171, white 255 -> 255, no console
errors)
- sim-exposure-test.cjs 9 PASS / 0 FAIL (classic 0.4149, vivid 0.4531, knob -1
returning 0.4177, bias 0.0382)
- regression suite, 28 probes: mask 53/0, brush-edit 35/0, heal-idle 23/0,
heal-zoom-drag 28/0, sims 31/0, sim-vivid 9/0, white-black 4/0, temp-swatch
33/0, tone-curve clean, compare 25/0, create 52/0, wb-preset 33/0, zoom 25/0,
save-recent 25/0, web-smoke 9/0 (its export step was stale — EXPORT opens a
size picker now). panel-test 4 FAIL, histogram-wb 1 FAIL, studio-save-hl and
progate timeouts, landing-test 6 FAIL ($0.99 pricing) are pre-existing.
- npx tsc --noEmit clean.
The histogram used to park itself in the top-right corner on the first
paint; it now starts at the top-left of the photo and is dragged from
there, the way the rest of the overlay is. Nothing else changed in it —
same drag, same clamping, same resize.
The stage also gains a CLEAR button, sitting before the picker button,
which is now OPEN PHOTO. CLEAR takes the photo off the stage, but not
before asking: SAVE PHOTO files it first and only then clears, EXPORT
IMAGE writes the JPEG and then clears, CLEAR WITHOUT SAVING drops it
there and then, and CANCEL leaves everything alone. Saving from that
modal resumes the clear once the file has really landed — a guest, a
capped account or a cancelled name prompt never loses the frame.
Clearing forgets the working photo (source, preview, GPS, ISO, and the
IndexedDB copy session.ts now deletes), while the look, the crop and the
undo history stay put, so the next photo opens on the same settings the
way replacing a photo already did.
Four things the studio owed the visitor:
- UNDO/REDO in the header, so a look can be taken back and put back without
reloading the photo; a fresh edit clears the redo trail.
- Opening a saved frame, or picking a look out of its history, now drops the
stale preview buffer instead of leaving the previous render on the stage.
- The picked history look is marked in the accent, so it is plain which look
the photo is wearing.
- The histogram is re-clamped against the photo box on resize, so opening a
chip column no longer pushes the overlay past the edge of the canvas.
The 'NEW SAVES: FILM STRIP' chip goes: a save already lands in the strip.
The panel in Lightroom is a running read of the render, so this one reads
the preview blob itself: one downscaled 320px canvas pass bins 256
values per channel, and four SVG paths draw them — the grey luma fill
with the three channel curves screened over it, from absolute black to
absolute white. Dragged by its header, clamped inside the photo, parked
top-right on first paint, and dismissed either from its own frame or from
the toolbar button. It steps aside while the crop frame is up.