diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 5384674..2575fb1 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -114,10 +114,11 @@ /* RESET is the way out of an edit, not one more chip: a rule and some air keep it apart from the tab's own buttons at the foot of the column. */ .reset-row { margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); } -/* Two things that belong to the phone alone: the path back up under the strip, - and LIGHT's panels as a chip row. A desktop draws neither — every column is - in sight, and LIGHT's panels are the column itself. */ -.crumb, .dev-chips { display: none; } +/* Three things that belong to the phone alone: the path back up under the strip, + LIGHT's panels as a chip row, and the strip those chips open. A desktop draws + none of them — every column is in sight, LIGHT's panels are the column itself, + and a strip beside them only repeats the seven rows standing in it. */ +.crumb, .dev-chips, .col-dev ~ .col-sub[data-col="options"] { display: none; } .chip-row { display: flex; flex-wrap: wrap; gap: 6px; } /* The rule a strip is read across (the mixer's PICK, its bands, the three knobs): a line of its own, so it breaks the row it is written into whichever @@ -556,14 +557,51 @@ button.hint:disabled { cursor: default; } cursor: pointer; } .curve-close:hover { color: var(--fg); } +.curve-tabs { display: flex; gap: 4px; align-items: center; } +.curve-tab { + display: flex; + align-items: center; + gap: 5px; + flex: 1 1 0; + justify-content: center; + padding: 3px 0; + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; +} +.curve-tab.on { border-color: var(--accent-line); color: var(--fg); } +/* The dot carries the channel's own colour, so the tab says which line it draws; + it goes amber once that channel has left the diagonal. */ +.curve-dot { width: 7px; height: 7px; border-radius: 999px; } +.curve-tab.moved .curve-dot { box-shadow: 0 0 0 2px var(--accent); } +.curve-reset { + flex: 0 0 auto; + padding: 3px 7px; + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.06em; + color: var(--muted); + background: none; + border: 1px solid var(--border); + border-radius: 3px; + cursor: pointer; +} +.curve-reset:hover { color: var(--fg); border-color: var(--accent-line); } .curve-graph { display: block; background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border); border-radius: 3px; cursor: crosshair; } /* The picture's own distribution, the floor the graph is read against. Light on both themes because the graph itself is always the dark box over a photo. */ .curve-hist { fill: rgba(255, 255, 255, 0.22); } .curve-grid { stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; } .curve-diagonal { stroke: rgba(255, 255, 255, 0.28); stroke-width: 1; stroke-dasharray: 3 3; } -/* The line the composite curve draws. */ +/* The line the active tab draws, and — on a channel tab — the composite curve + under it, so the two moves can be told apart. */ .curve-line { fill: none; stroke-width: 2; stroke-linecap: round; } +.curve-line.ghost { stroke: rgba(255, 255, 255, 0.35); stroke-width: 1; stroke-dasharray: 4 3; } .curve-point { fill: var(--bg-elev); stroke-width: 2; cursor: grab; } .curve-point:active { cursor: grabbing; } @@ -1772,6 +1810,9 @@ input[type="range"].track-temp::-webkit-slider-thumb, input[type="range"].track- the wrapper's place, exactly as .chips itself stands in its columns'. */ .col-dev .dev-chips { display: contents; } .col-dev .dev-panels { display: none; } + /* The strip those chips open is the phone's whole way to a row, so it comes + back with them (see the base rule that hides it on a desktop). */ + .col-dev ~ .col-sub[data-col="options"] { display: flex; } /* --- the way back up: the path under the strip -------------------------- */ /* TABS > LIGHT > WB. The bar is as deep as the visitor walked, so the bar's diff --git a/docker/frontend/src/ui/ToneCurvePanel.tsx b/docker/frontend/src/ui/ToneCurvePanel.tsx index 9d67d1f..d318831 100644 --- a/docker/frontend/src/ui/ToneCurvePanel.tsx +++ b/docker/frontend/src/ui/ToneCurvePanel.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react'; -import type { ToneCurve, ToneCurvePoint } from '../../shared/types'; +import type { ToneCurve, ToneCurveChannel, ToneCurvePoint } from '../../shared/types'; import { readHistogram, type Histogram } from './Histogram'; import { + CURVE_CHANNELS, CURVE_CHANNEL_COLOR, addCurvePoint, curvePoints, @@ -11,10 +12,9 @@ import { sampleCurve, } from '../../shared/utils/toneCurve'; -// LIGHT > TONE CURVE: the graph that hangs on the photo. One curve — the -// composite one, the `rgb` key — drawn over a grid, with the points the user -// drags. The per-channel tabs are gone: the card is the graph and nothing else. -// It reads and writes nothing but that one key of the `toneCurve` field, so the +// LIGHT > TONE CURVE: the graph that hangs on the photo. Four curves — the +// composite one and one per channel — drawn over a grid, with the points the +// user drags. It reads and writes nothing but the `toneCurve` field, so the // render is the only other thing that has to know what a curve is (see // toneCurve.ts for the table it hands the renderer). // @@ -29,7 +29,12 @@ import { const SIZE = 224; // the graph's own square, in px const STEPS = 64; // how finely the line is drawn const HIT = 11; // px within which the pointer takes hold of a point +const NEEDS = 0.002; // how far off the diagonal counts as a move, as in toneCurve.ts + +const LABEL: Record = { rgb: 'RGB', r: 'R', g: 'G', b: 'B' }; + const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v); +const offDiagonal = (pts: ToneCurvePoint[]) => pts.some((p) => Math.abs(p[1] - p[0]) > NEEDS); export function ToneCurvePanel({ curve, @@ -42,6 +47,7 @@ export function ToneCurvePanel({ onChange: (next: ToneCurve) => void; onClose: () => void; }) { + const [channel, setChannel] = useState('rgb'); const [hist, setHist] = useState(null); const [pos, setPos] = useState<{ x: number; y: number } | null>(null); const [dragging, setDragging] = useState(false); @@ -70,14 +76,15 @@ export function ToneCurvePanel({ }; }, [url]); - const pts = curvePoints(curve, 'rgb'); + const pts = curvePoints(curve, channel); + const master = curvePoints(curve, 'rgb'); - // A curve pulled back to the diagonal stores nothing: the recipe stays as + // A channel pulled back to the diagonal stores nothing: the recipe stays as // small as the curve is flat, and an old recipe reads the same either way. const put = (next: ToneCurvePoint[]): ToneCurve => { const out: ToneCurve = { ...curve }; - if (isFlatCurve(next)) delete out.rgb; - else out.rgb = next; + if (isFlatCurve(next)) delete out[channel]; + else out[channel] = next; return out; }; @@ -224,9 +231,9 @@ export function ToneCurvePanel({ const grid = [0, 1, 2, 3, 4].map((i) => (i * SIZE) / 4); // What the graph is drawn against: the luminance of the picture on the grey - // channel — the shape of the whole frame, since the composite curve is the - // only one the card draws. - const bins = hist ? hist.lum : null; + // channel, and that channel's own distribution when a single channel is being + // bent — the histogram follows the tab, the way Lightroom's does. + const bins = hist ? (channel === 'rgb' ? hist.lum : hist[channel]) : null; return (
+
+ {CURVE_CHANNELS.map((ch) => ( + + ))} + +
+ {channel !== 'rgb' ? : null} {pts.map((p, i) => ( @@ -308,7 +348,7 @@ export function ToneCurvePanel({ cx={p[0] * SIZE} cy={(1 - p[1]) * SIZE} r={i === 0 || i === pts.length - 1 ? 6 : 5} - style={{ stroke: CURVE_CHANNEL_COLOR.rgb }} + style={{ stroke: CURVE_CHANNEL_COLOR[channel] }} /> ))}