studio: the develop column becomes five panels, and the four tone knobs move knots instead of channels

Two specs, one commit: the develop state becomes the panel column the Lightroom
spec draws, and HIGHLIGHT, SHADOW, WHITE and BLACK stop being edits and become
shapes of the tone curve, the way the mapping spec measures them.

The column. The left rail used to hand LIGHT a row of chips and nothing else;
the four tone knobs were chips that opened a curve, and the rest of the develop
state lived in the chip row's own vocabulary. `DevelopPanels` renders the five
sections of the spec instead — PROFILE, WB, TONE, PRESENCE, DETAIL & EFFECTS —
as an accordion, all open, and every parameter the recipe holds has a row in it
with a `data-key` off the parameter name: slider, value readout, double-click to
default. Sliders are always visible, so a knob is one drag away instead of two
taps, and TEMPERATURE and TINT draw their gradient underneath (blue through amber,
green through pink) so the direction is on the control. The PRO looks the spec
marks stay in the list but locked, tagged PRO, and tapping one asks for PRO —
they are shown, not hidden, and not silently dropped.

WHITE and BLACK move out of the WB group. They were the temperature group's
extremes, which is what a white balance control does — the toe and the shoulder
of the same ramp — but the spec puts them with the tone knobs and gives them the
two ends of the tone curve, and that is what they now are. `wb` is TEMPERATURE
and TINT and nothing else; `whites` and `blacks` sit in `iq` beside `highlights`
and `shadows`, labelled WHITE and BLACK, in the tone panel where the slider lives.
A recipe written before this commit still reads: the keys are unchanged.

The four knobs. The first pass of the mapping spec added a mask per zone onto the
channel: `luma += knob * mask * intensity`, and the shader followed it. It is the
wrong shape, and the twin harness in `highlight-knee-check.mjs` shows why — the
four masks are not a partition of the ramp. They sum to one at the ends and to
zero at the midpoint, so an adjustment in the middle of a zone is applied where
the mask is half and not at all where the mask has fallen to nothing, and the
ramp inverts: with every knob at its stop the curve folds over itself, slope −5
at t=0.87, and the twin catches it as a non-monotone ramp.

So each knob moves a knot on the curve instead, which is the reading the spec's
own mask geometry points at — BLACK peak at 0.00, SHADOW 0.00→0.25→0.50,
HIGHLIGHT 0.50→0.75→1.00, WHITE peak at 1.00 — and the shader builds the curve
through those four anchors. `TONE_ANCHOR` is 0.25: one full knob at its stop is a
quarter of the range at that knot, so the range is 0.75..1.00 at the top and
0.00..0.25 at the bottom, and the anchors stay ordered (`a0 ≤ a1 ≤ 0.5 ≤ a3 ≤ a4`)
by clamping each against its neighbour. Between knots the curve is a straight
line, and 0.5 is untouched by every knob, so a knob at zero is the identity
exactly rather than nearly, and any combination of the four is monotone. The mask
sum survives where the spec is right about it: it hints the split between the two
dark zones and the two light ones, nothing else.

The hue is kept the way the spec keeps it: work in luma, then scale the chroma
offset — `rgb = luma_new + (rgb - luma_old) * luma_new / luma_old` — so a
saturated red stays the same red and only its brightness moves. The ratio is
clamped to 0.55..1.35 because at luma near zero the division is the whole
highlight of the picture on one code value.

Verified:

- `node scripts/highlight-knee-check.mjs` passes. It pins the settled shader —
  four masks, four anchors, the four `mix` lines — and asserts the constructions
  it replaced are gone, then drives a twin of the ramp in JS: the masks do not
  overlap, every knob at zero is the identity, the midpoint is 0.5 for all 162
  combinations of the four knobs, every combination is monotone, the amplitude at
  each stop is a quarter, and the DR offsets land on 0.12 and 0.82. The folded
  case from the additive build is in the harness as a regression.
- `npx tsc --noEmit` clean; `npm run build` emits `index-DXIIw2F1.js` and
  `index-A4pA1U5f.css`; `library-check.mjs`, `scan-nav-check.mjs`,
  `roll-walk-check.mjs`, `auto-tone-check`, `half-check`, `preview-match-check`
  and `white-level-check` all pass against the bundle — the catalogue, the RAW
  path, auto tone and the white level are untouched by the panel move.
- Driven in a real browser (`tone-live-check.mjs`, Chromium against
  `vite preview`, a P1010256.JPG in the source control, mean luma of the preview
  canvas read before and after each knob): neutral 184.25, WHITE +1 187.35,
  BLACK +1 186.35, SHADOW +1 194.53, EXPOSURE +1 206.99, HIGHLIGHT −1 173.80.
  Every knob moves the picture the way the spec says it should and none of them
  moves it much — a stop of a knob is a quarter of a zone, not a level.
- The same run asserts the built DOM: five panels, the 23 `data-key` rows,
  `dev-temperature` in WB, `dev-whites`, `dev-blacks`, `dev-highlight` and
  `dev-shadow` together in TONE, the gradient classes on the two white balance
  sliders, and the chip slots the panel is handed. The only failed request is
  `/api/events`, which is the backend this preview does not run.

ponytail: the recovery of blown highlights that used to sit under HIGHLIGHT — a
per-channel rolloff in linear light — is gone, deleted rather than ported. The
additive mask is why it was there: HIGHLIGHT had to do two jobs because a mask
could not shape a curve. Now that WHITE owns the top end, HIGHLIGHT only bends,
and the per-channel rolloff is a second knob for the same picture. Bring it back
as its own parameter if a frame ever clips badly enough to need it.

Also dropped: DR used to ride along as two additive terms. That is where the fold
at t=0.238 came from, BLACK −1 and SHADOW −1 together — the two terms pushed the
ramp past its own end. It shifts the knots now, which is what the film sims
always meant by it, and the numbers in the sims were kept and their meaning
recommented (classic-chrome toe 0.22, head 0.7375, etc.).

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 16:38:56 +07:00
parent 64d41f67e9
commit 34f8601c91
7 changed files with 618 additions and 199 deletions
+66 -10
View File
@@ -50,6 +50,7 @@ import { HEAL_DEFAULT_R } from '../shared/utils/heal';
import { MOSAIC_DEFAULT_R } from '../shared/utils/mosaic';
import { MASK_DEFAULT_FEATHER, MASK_EXPOSURE_MAX } from '../shared/utils/gradientMask';
import { readHistogram, autoExposureStops, autoTone, autoWhiteBalance } from './ui/Histogram';
import { DevelopPanels } from './ui/DevelopPanels';
import type { MsgKey } from './i18n/vi';
// Mirrors the API's MAX_PHOTOS_PER_USER: shown on SAVE PHOTO, enforced there.
@@ -2755,16 +2756,15 @@ export function Workspace() {
return rows;
}
case 'light':
// LIGHT is the one tab whose knobs are not chips: the sidebar
// (DevelopPanels) puts every one of them on screen at once, so only the
// two things that are NOT knobs stay here — AUTO, which is an action
// (see autoTune), and TONE CURVE, which opens the graph on the photo
// (ToneCurvePanel) rather than a ruler in the last column. It glows amber
// once the graph is off the diagonal, which is the only place the curve
// is reported.
return [
// AUTO rides at the head of the strip because it is the one chip here
// that is an ACTION rather than a knob or a look (see autoTune).
{ key: 'auto', label: 'AUTO', onClick: () => void autoTune() },
...paramChips(PARAM_DEFS.iq),
groupChip('dr'),
// TONE CURVE is not a row of sliders: it opens the graph on the photo
// (ToneCurvePanel), so the chip toggles that overlay rather than a
// ruler in the last column. It glows amber once the graph is off the
// diagonal, which is the only place the curve is reported.
{
key: 'curve',
label: 'TONE CURVE',
@@ -3032,6 +3032,41 @@ export function Workspace() {
})),
];
// The studio sidebar's three slots (DevelopPanels): the picks that name a
// whole look rather than a number, so they stay strips of their own inside the
// panel that owns them — SIM and D.RANGE with the tone controls, the WB
// presets with the two WB tracks, the two Color Chromes at the foot of the
// effects column. Every one of them is the strip that already existed, so a
// pick made here is the pick made on the tab it came from.
const developSlots = {
profile: (
<>
<ChipRow chips={choiceChips('sim', groupDefs.style.options, groupDefs.style.value, groupDefs.style.onPick, simPreview)} />
<ChipRow
chips={choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick)}
/>
</>
),
// COLOR TEMP is left out: the ruler is the panel's own TEMPERATURE row, so a
// chip that opened the same ruler would be a second way to the same knob.
wb: (
<ChipRow
chips={choiceChips('temp', WB_PRESETS.map((p) => ({ v: p.key, d: p.label })), wbValue(), (v) => {
remember();
setWbChoice(v);
const p = WB_PRESETS.find((w) => w.key === v) ?? WB_PRESETS[0];
setAdjustment({ temperature: p.kelvin, tint: p.tint });
})}
/>
),
effects: (
<>
<ChipRow chips={choiceChips('cx', CHROME_OPTIONS, recipe.adjustments.colorChrome || 'none', groupDefs.cx.onPick)} />
<ChipRow chips={choiceChips('cxb', CHROME_OPTIONS, recipe.adjustments.colorChromeBlue || 'none', groupDefs.cxb.onPick)} />
</>
),
};
// The panel is a cascade of columns (see styles/app.css): the tab's chips, the
// open chip's own panel, the open group's options, the open ruler. Each level
// is a column of its own, so a child never hides the column it came from.
@@ -3115,7 +3150,12 @@ export function Workspace() {
<div className="chips">
{/* column 1 — the tab's own chips, RESET ruled off at the foot */}
<div className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}`} data-col="main">
<div
className={`col col-main${tab === 'create' || tab === 'save' ? ' col-create' : ''}${
tab === 'light' ? ' col-dev' : ''
}`}
data-col="main"
>
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
@@ -3172,7 +3212,23 @@ export function Workspace() {
)}
</>
) : (
<ChipRow chips={chips} />
<>
{/* LIGHT's column is the sidebar itself: the two chips that are
not knobs, then every knob of the tab in one stack, all of
them open at once. */}
{tab === 'light' ? <ChipRow chips={chips} /> : null}
{tab === 'light' ? (
<DevelopPanels
adj={recipe.adjustments}
set={setAdjustmentOnce}
pro={pro}
onPro={promptPro}
slots={developSlots}
/>
) : (
<ChipRow chips={chips} />
)}
</>
)}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
{/* The mixer's readout: the colour the eyedropper last read, as the
+42
View File
@@ -1274,6 +1274,48 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
.stats-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); }
.stats-n { font-family: var(--mono); font-variant-numeric: tabular-nums; }
/* --- studio sidebar (DevelopPanels) ------------------------------------- */
/* Lightroom's right column: every panel's knobs are on screen at once, each
panel a disclosure that folds its own rows away. Colour comes from the tokens
only, so a theme or accent change lands here with no rules of its own. */
/* The sidebar (DevelopPanels) draws rows, not chips, so its column is the wide
one: the same width CREATE RECIPES takes, and the chips that are not knobs
(AUTO, TONE CURVE) stack over it, wrapped in the column's own .chip-row. */
.col-dev { width: 320px; }
.dev-panels { display: flex; flex-direction: column; gap: 10px; }
.dev-panel { border-top: 1px solid var(--border-soft); padding-top: 8px; }
.dev-panel-head {
display: flex; justify-content: space-between;
width: 100%; padding: 0; border: 0; background: transparent;
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text); cursor: pointer;
}
.dev-panel-head:hover { color: var(--accent); }
.dev-panel-body { display: flex; flex-direction: column; gap: 10px; padding-top: 8px; }
/* A row that cannot be edited at all: a PRO knob on the LITE build. It reads as
a row, not as a button — the tag is what says the row is not a slider. */
.dev-row { display: flex; flex-direction: column; gap: 4px; }
.dev-row.locked {
flex-direction: row; align-items: center; gap: 6px;
width: 100%; padding: 0; border: 0; background: transparent;
text-align: left; opacity: 0.5; cursor: not-allowed;
}
.dev-pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
/* WB's two knobs: the track is the hue the value moves towards, the way
Lightroom's temperature and tint sliders are painted. Everything else keeps
the plain input's accent color. */
.track-temp, .track-tint {
height: 4px; border-radius: 999px;
appearance: none; -webkit-appearance: none;
}
.track-temp { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); }
.track-tint { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); }
.track-temp::-webkit-slider-thumb, .track-tint::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: 12px; height: 12px; border-radius: 50%;
background: var(--text); border: none; cursor: pointer;
}
/* --- responsive --------------------------------------------------------- */
@media (max-width: 860px) {
.workspace { flex-direction: column; }
+16 -2
View File
@@ -162,6 +162,9 @@ export function MiniSlider({
step = 1,
prefix = 'hsl-knob',
format,
defaultValue = 0,
track,
dataKey,
onChange,
onReset,
}: {
@@ -172,6 +175,16 @@ export function MiniSlider({
step?: number;
prefix?: string;
format?: (value: number) => string;
// The value the readout stays grey at. 0 is the mixer's own default, but the
// studio's WB rows are not the only knobs here: a temperature's default is
// 5500K and a grain size's is 100, so the caller that knows says so.
defaultValue?: number;
// A coloured track for the two WB knobs (CSS track-temp / track-tint), whose
// travel is a hue rather than a number.
track?: 'temp' | 'tint';
// Replaces the `<prefix>-<label>` key for a caller that addresses a row by the
// parameter's own key (the sidebar's dev-<key>) instead of its label.
dataKey?: string;
onChange: (value: number) => void;
onReset: () => void;
}) {
@@ -194,10 +207,10 @@ export function MiniSlider({
return () => el.removeEventListener('wheel', onWheel);
}, [min, max, step]);
return (
<div className="mini-slider" ref={ref} data-key={`${prefix}-${label}`}>
<div className="mini-slider" ref={ref} data-key={dataKey ?? `${prefix}-${label}`}>
<div className="mini-head">
<span>{label}</span>
<b className={value !== 0 ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
<b className={value !== defaultValue ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
{format ? format(value) : value > 0 ? `+${value}` : String(value)}
</b>
</div>
@@ -208,6 +221,7 @@ export function MiniSlider({
step={step}
value={value}
aria-label={label}
className={track ? `track-${track}` : undefined}
onChange={(e) => onChange(Number(e.target.value))}
onDoubleClick={onReset}
/>
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { ColorAdjustments } from '../../shared/types';
import { PARAM_DEFS } from '../../shared/utils/paramDefs';
import { MiniSlider } from './ChipColumn';
// One lookup for every row. The defs are split over the three tabs' tables and
// the sidebar reads them all at once, so the map is built once here; a key that
// is not in it draws no row rather than a broken one.
const DEF_BY_KEY = new Map(
[...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters].map((def) => [def.key, def])
);
// The stack, top to bottom, and the rows each panel owns. The two WB tracks are
// the only rows whose travel is a colour, so the class each input wears is named
// here with them; anything else is the bare slider.
const TRACK: Record<string, 'temp' | 'tint' | undefined> = { temperature: 'temp', tint: 'tint' };
const PANELS: {
slug: string;
title: string;
rows: string[];
// A slot rides inside its panel, where the parent's own select belongs in the
// reading order — before the panel's knobs except in DETAIL & EFFECTS, whose
// COLOR CHROME selects close the column.
slot?: 'profile' | 'wb' | 'effects';
slotFirst?: boolean;
}[] = [
{ slug: 'profile', title: 'PROFILE', rows: ['ev'], slot: 'profile', slotFirst: true },
{ slug: 'wb', title: 'WB', rows: ['temperature', 'tint'], slot: 'wb', slotFirst: true },
{ slug: 'tone', title: 'TONE', rows: ['exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'] },
{ slug: 'presence', title: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] },
{
slug: 'effects',
title: 'DETAIL & EFFECTS',
rows: ['denoise', 'sharpening', 'vignette', 'hdf', 'grain', 'grainSize'],
slot: 'effects',
},
];
function DevRow({
paramKey,
adj,
set,
pro,
onPro,
}: {
paramKey: string;
adj: ColorAdjustments;
set: (patch: Partial<ColorAdjustments>) => void;
pro: boolean;
onPro?: () => void;
}) {
const def = DEF_BY_KEY.get(paramKey);
if (!def) return null;
// A PRO knob on the LITE build is a label with nothing under it: the value
// stays at its default, and the row's own click is handed to the parent's
// upgrade path instead of a slider that would move nothing on the photo.
if (def.pro && !pro) {
return (
<button type="button" className="dev-row locked" data-key={`dev-${paramKey}`} onClick={() => onPro?.()}>
{def.label}
<span className="dev-pro">PRO</span>
</button>
);
}
return (
<MiniSlider
label={def.label}
value={def.get(adj)}
min={def.min}
max={def.max}
step={def.step}
defaultValue={def.defaultValue}
track={TRACK[paramKey]}
format={def.display}
prefix="dev"
dataKey={`dev-${paramKey}`}
onChange={(v) => set(def.set(v))}
onReset={() => set(def.set(def.defaultValue))}
/>
);
}
// The studio's right column: every panel's knobs on screen at once, each panel a
// disclosure that folds its own rows away. One undo step is the parent's `set`,
// so nothing here holds state but which panels are open.
export function DevelopPanels({
adj,
set,
pro = false,
onPro,
slots,
}: {
adj: ColorAdjustments;
set: (patch: Partial<ColorAdjustments>) => void;
pro?: boolean;
onPro?: () => void;
slots?: { profile?: ReactNode; wb?: ReactNode; effects?: ReactNode };
}) {
const [open, setOpen] = useState<Record<string, boolean>>(() =>
Object.fromEntries(PANELS.map((panel) => [panel.slug, true]))
);
return (
<div className="dev-panels">
{PANELS.map((panel) => {
const isOpen = open[panel.slug];
const slot = panel.slot ? slots?.[panel.slot] : null;
return (
<section key={panel.slug} className="dev-panel" data-key={`dev-panel-${panel.slug}`}>
<button
type="button"
className="dev-panel-head"
aria-expanded={isOpen}
onClick={() => setOpen((o) => ({ ...o, [panel.slug]: !isOpen }))}
>
{panel.title}
<span aria-hidden="true">{isOpen ? '▾' : '▸'}</span>
</button>
{isOpen ? (
<div className="dev-panel-body">
{panel.slotFirst ? slot : null}
{panel.rows.map((key) => (
<DevRow key={key} paramKey={key} adj={adj} set={set} pro={pro} onPro={onPro} />
))}
{panel.slotFirst ? null : slot}
</div>
) : null}
</section>
);
})}
</div>
);
}