web: fold the create form's groups and add the white/black point rows

The CREATE RECIPES form had grown into one long scroll. The six categorical
groups (SIMULATION, DYNAMIC RANGE, GRAIN EFFECT, COLOR CHROME EFFECT, COLOR
CHROME EFFECT BLUE, WHITE BALANCE) are now native <details> folds — the browser
keeps the open flag, so no state and no library.

The two tone-curve ENDS join the numeric grid: EXPOSURE (the matrix gain the IQ
tab already drives), EV (the old EXPOSURE COMP. row, renamed to the phone's
word), WHITE and BLACK. WHITE/BLACK are new ColorAdjustments fields, applied in
TONE_SKSL as cubic end-weights rather than another smoothstep knee — the HL/SH
knees already spend the slope budget, and the cubic keeps the curve monotonic
for every combination (derivative >= 0.46), so a brighter input can still never
come out darker. Both are optional, so stored recipes keep working.
This commit is contained in:
2026-09-18 12:30:36 +07:00
parent 567f650bbb
commit f17cb08a63
4 changed files with 112 additions and 51 deletions
+82 -50
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { ChipRow, type ChipDef } from './ChipColumn';
import { useI18n } from '../i18n/I18nProvider';
import { DEFAULT_ADJUSTMENTS, FILM_SIMS } from '../../shared/utils/defaultRecipes';
@@ -51,6 +51,17 @@ const wbKeyOf = (s: ColorAdjustments) =>
return p.temperature === s.temperature && p.tint === s.tint;
}) ?? 'custom';
// One collapsible group. A native <details> — no state, no library, and the
// browser keeps the open/closed flag itself (the `open` prop only seeds it).
function Fold({ label, children }: { label: string; children: ReactNode }) {
return (
<details className="create-sec" open>
<summary className="create-label">{label}</summary>
{children}
</details>
);
}
export function RecipeCreatePanel({
currentBase,
current,
@@ -89,6 +100,9 @@ export function RecipeCreatePanel({
wbKey: wbKeyOf(s),
highlight: String(s.highlight),
shadow: String(s.shadow),
whites: String(s.whites ?? 0),
blacks: String(s.blacks ?? 0),
exposure: String(s.exposure),
color: String(s.saturation),
nr: String(s.denoise),
sharp: String(s.sharpening),
@@ -108,6 +122,9 @@ export function RecipeCreatePanel({
const [wbBlue, setWbBlue] = useState(String(seedAdj.wbBlue ?? 0));
const [fHigh, setFHigh] = useState(String(seedAdj.highlight));
const [fShadow, setFShadow] = useState(String(seedAdj.shadow));
const [fWhites, setFWhites] = useState(String(seedAdj.whites ?? 0));
const [fBlacks, setFBlacks] = useState(String(seedAdj.blacks ?? 0));
const [fExposure, setFExposure] = useState(String(seedAdj.exposure));
const [fColor, setFColor] = useState(String(seedAdj.saturation));
const [fNr, setFNr] = useState(String(seedAdj.denoise));
const [fSharp, setFSharp] = useState(String(seedAdj.sharpening));
@@ -125,6 +142,9 @@ export function RecipeCreatePanel({
setWbKey(v.wbKey);
setFHigh(v.highlight);
setFShadow(v.shadow);
setFWhites(v.whites);
setFBlacks(v.blacks);
setFExposure(v.exposure);
setFColor(v.color);
setFNr(v.nr);
setFSharp(v.sharp);
@@ -146,6 +166,9 @@ export function RecipeCreatePanel({
wbBlue: int(wbBlue, 0, -9, 9),
highlight: int(fHigh, simAdj.highlight, -10, 10),
shadow: int(fShadow, simAdj.shadow, -10, 10),
whites: int(fWhites, simAdj.whites ?? 0, -10, 10),
blacks: int(fBlacks, simAdj.blacks ?? 0, -10, 10),
exposure: int(fExposure, simAdj.exposure, -10, 10),
saturation: int(fColor, simAdj.saturation, -10, 10),
denoise: int(fNr, simAdj.denoise, -10, 10),
sharpening: int(fSharp, simAdj.sharpening, -10, 10),
@@ -182,7 +205,7 @@ export function RecipeCreatePanel({
const { baseFilter, adjustments } = build();
onPreview({ baseFilter, adjustments });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sim, dr, grain, cc, ccb, wbKey, wbRed, wbBlue, fHigh, fShadow, fColor, fNr, fSharp, fClarity, fEv]);
}, [sim, dr, grain, cc, ccb, wbKey, wbRed, wbBlue, fHigh, fShadow, fWhites, fBlacks, fColor, fNr, fSharp, fClarity, fExposure, fEv]);
const opts = (
prefix: string,
@@ -232,71 +255,80 @@ export function RecipeCreatePanel({
/>
</label>
<span className="create-label">SIMULATION</span>
<ChipRow
chips={FILM_SIMS.map((s) => ({
key: s.id,
label: s.name,
active: sim === s.baseFilter,
onClick: () => selectSim(s.baseFilter),
}))}
/>
<Fold label="SIMULATION">
<ChipRow
chips={FILM_SIMS.map((s) => ({
key: s.id,
label: s.name,
active: sim === s.baseFilter,
onClick: () => selectSim(s.baseFilter),
}))}
/>
</Fold>
<span className="create-label">DYNAMIC RANGE</span>
<ChipRow chips={opts('dr', DR_OPTS, dr, setDr)} />
<Fold label="DYNAMIC RANGE">
<ChipRow chips={opts('dr', DR_OPTS, dr, setDr)} />
</Fold>
<div className="create-grid">
{[
{ label: 'HIGHLIGHT', value: fHigh, set: setFHigh },
{ label: 'SHADOW', value: fShadow, set: setFShadow },
{ label: 'WHITE', value: fWhites, set: setFWhites },
{ label: 'BLACK', value: fBlacks, set: setFBlacks },
{ label: 'COLOR', value: fColor, set: setFColor },
{ label: 'NOISE RED.', value: fNr, set: setFNr, ph: '-10..10' },
{ label: 'SHARPENING', value: fSharp, set: setFSharp, ph: '-10..10' },
{ label: 'CLARITY', value: fClarity, set: setFClarity },
{ label: 'EXPOSURE COMP.', value: fEv, set: setFEv, ph: 'EV' },
{ label: 'EXPOSURE', value: fExposure, set: setFExposure },
{ label: 'EV', value: fEv, set: setFEv, ph: '±3 EV' },
].map((f) => num(f.label, f.value, f.set, f.ph))}
</div>
<span className="create-label">GRAIN EFFECT</span>
<ChipRow chips={opts('grain', GRAIN_OPTS, grain, setGrain)} />
<Fold label="GRAIN EFFECT">
<ChipRow chips={opts('grain', GRAIN_OPTS, grain, setGrain)} />
</Fold>
<span className="create-label">COLOR CHROME EFFECT</span>
<ChipRow chips={opts('cc', CC_OPTS, cc, setCc)} />
<Fold label="COLOR CHROME EFFECT">
<ChipRow chips={opts('cc', CC_OPTS, cc, setCc)} />
</Fold>
<span className="create-label">COLOR CHROME EFFECT BLUE</span>
<ChipRow chips={opts('ccb', CC_OPTS, ccb, setCcb)} />
<Fold label="COLOR CHROME EFFECT BLUE">
<ChipRow chips={opts('ccb', CC_OPTS, ccb, setCcb)} />
</Fold>
<span className="create-label">WHITE BALANCE</span>
<ChipRow
chips={[
...opts('wb', WB_PRESETS.map((w) => ({ v: w.key, d: w.label })), wbKey, setWbKey),
...(wbKey === 'custom'
? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }]
: []),
]}
/>
<div className="create-shift">
<span className="create-label">WB SHIFT</span>
<span className="create-label">RED</span>
<input
className="field"
data-key="create-wbRed"
value={wbRed}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbRed(e.target.value)}
<Fold label="WHITE BALANCE">
<ChipRow
chips={[
...opts('wb', WB_PRESETS.map((w) => ({ v: w.key, d: w.label })), wbKey, setWbKey),
...(wbKey === 'custom'
? [{ key: 'wb:custom', label: `${seedAdj.temperature}K`, active: true, onClick: () => setWbKey('custom') }]
: []),
]}
/>
<span className="create-label">BLUE</span>
<input
className="field"
data-key="create-wbBlue"
value={wbBlue}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbBlue(e.target.value)}
/>
</div>
<div className="create-shift">
<span className="create-label">WB SHIFT</span>
<span className="create-label">RED</span>
<input
className="field"
data-key="create-wbRed"
value={wbRed}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbRed(e.target.value)}
/>
<span className="create-label">BLUE</span>
<input
className="field"
data-key="create-wbBlue"
value={wbBlue}
placeholder="-9..9"
inputMode="numeric"
onChange={(e) => setWbBlue(e.target.value)}
/>
</div>
</Fold>
<div className="create-shift">
<span className="create-label">ISO</span>