feat(panel): cascading columns, WB colour swatches, stronger HDF glow

Layout
- the panel is a cascade of columns: the rail's tabs, the tab's chips, the
  open chip's sub-chips, then the ruler. A child column no longer hides the
  column it came from (TEMP -> COLOR TEMP keeps TEMP visible); chips stack one
  per row instead of wrapping
- FRAME's WATERMARK opens its own column, so the frame chips stay put
- CREATE RECIPES gets the wide column its two-up form needs

WB colour swatches
- the ruler draws a colour box under the slider that follows the value:
  COLOR TEMP is the Kelvin colour (Tanner Helland), TINT runs green -10 ->
  neutral 0 -> magenta +10

HDF EFFECT
- knee 0.55..0.85 -> 0.45..0.75, blur 0.004+0.015n -> 0.006+0.024n of the
  width, screen alpha 0.15+0.35n -> 0.28+0.52n: a wide halo on the highlights
  instead of a hairline glow. Web copy of toneShader only — the phone keeps
  its own tuning.

Tabs
- rail order is PRESETS, FAVORITED, WB, LIGHT, FX, FRAME, CREATE RECIPES
This commit is contained in:
2026-09-18 06:42:38 +07:00
parent ffdefd2c9c
commit f13fc37b5a
7 changed files with 198 additions and 77 deletions
+102 -50
View File
@@ -64,6 +64,37 @@ const TEMP_PRESETS: { label: string; kelvin: number }[] = [
{ label: 'FLUOR', kelvin: 4000 },
];
// Kelvin -> sRGB (Tanner Helland's approximation, what every WB widget uses):
// 2500K reads warm orange, 5500K a near-neutral white, 10000K blue.
function kelvinToHex(k: number): string {
const t = Math.min(40000, Math.max(1000, k)) / 100;
const chan = (v: number) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0');
const r = t <= 66 ? 255 : 329.698727446 * (t - 60) ** -0.1332047592;
const g = t <= 66 ? 99.4708025861 * Math.log(t) - 161.1195681661 : 288.1221695283 * (t - 60) ** -0.0755148492;
const b = t >= 66 ? 255 : t <= 19 ? 0 : 138.5177312231 * Math.log(t - 10) - 305.0447927307;
return `#${chan(r)}${chan(g)}${chan(b)}`;
}
// HSL -> #rrggbb (CSS keeps hsl() opaque to a test, and the swatch prints its
// own hex, so the panel hands out one format).
function hslToHex(h: number, s: number, l: number): string {
const k = (n: number) => (n + h / 30) % 12;
const a = (s / 100) * Math.min(l / 100, 1 - l / 100);
const chan = (n: number) =>
Math.max(0, Math.min(255, Math.round(255 * (l / 100 - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1))))))
.toString(16)
.padStart(2, '0');
return `#${chan(0)}${chan(8)}${chan(4)}`;
}
// The knobs that drive a colour get a swatch under their ruler, so the hue is
// visible next to the number. TINT runs green (-10) through a neutral grey (0)
// to magenta (+10) — the axis every WB tool labels G..M.
const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
temperature: kelvinToHex,
tint: (v) => hslToHex(140 + ((v + 10) / 20) * 160, Math.abs(v) * 7, 58 + (10 - Math.abs(v)) * 3),
};
// The option strips, one open at a time (phone's GroupKey set minus the groups
// the web engine has no knob for).
type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'wm'
@@ -664,13 +695,18 @@ export function Workspace() {
}, [engine, source, recipe, frameId, cropEditing, cropLive, cropRect, useGeotag, gps, gpsStyle, rotation, markOn, markText, markStyle, place, user, t]);
// --- strips ---------------------------------------------------------------
// One row at a time, exactly the phone's rule: opening a ruler closes the
// option strip (and vice-versa), and either closes the expanded recipes.
// The cascade: a chip opens the next column, and the column it came from stays
// put (TEMP -> COLOR TEMP leaves TEMP visible with the ruler beside it). The
// recipes list is the one exception — it belongs to PRESETS, not to a chip, so
// anything else opening closes it.
const toggleParam = useCallback(
(key: string) => {
setOpenGroup(null);
const close = openParam === key;
setOpenParam(close ? null : key);
setShowRecipes(false);
setOpenParam(openParam === key ? null : key);
// Opening a ruler clears a strip from another branch; closing one leaves
// its own strip standing, so the column the ruler came from is still there.
if (!close) setOpenGroup(PARAM_GROUP[key] ?? null);
},
[openParam]
);
@@ -679,12 +715,13 @@ export function Workspace() {
(g: GroupKey) => {
const next = openGroup === g ? null : g;
if (next) {
setOpenParam(null);
// A ruler only survives if it belongs to the strip being opened.
if (PARAM_GROUP[openParam ?? ''] !== next) setOpenParam(null);
setShowRecipes(false);
}
setOpenGroup(next);
},
[openGroup]
[openGroup, openParam]
);
useEffect(() => {
@@ -715,15 +752,17 @@ export function Workspace() {
const all = [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters];
const def = all.find((d) => d.key === openParam);
if (!def) return null;
const value = def.get(recipe.adjustments);
return {
key: def.key,
label: def.label,
value: def.get(recipe.adjustments),
value,
min: def.min,
max: def.max,
step: def.step,
display: def.display(def.get(recipe.adjustments)),
edited: def.get(recipe.adjustments) !== def.defaultValue,
display: def.display(value),
edited: value !== def.defaultValue,
swatch: SLIDER_SWATCH[def.key]?.(value),
onChange: (value) => setAdjustmentOnce(def.set(value)),
// Back reopens the strip the row was launched from (COLOR TEMP -> TEMP).
onBack: () => {
@@ -1002,9 +1041,9 @@ export function Workspace() {
case 'fx':
return paramChips(PARAM_DEFS.filters);
case 'frame':
return isWmPanel(openGroup)
? [{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) }, ...wmChips()]
: framesRow();
// WATERMARK is a chip like the rest: its own sub-panel opens as the next
// column (see the wm column in the render), so the frame chips stay put.
return framesRow();
// CREATE draws its own form (ui/RecipeCreatePanel) instead of a chip row.
case 'create':
return [];
@@ -1045,6 +1084,12 @@ export function Workspace() {
})),
];
// 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.
const wmOpen = isWmPanel(openGroup);
const optGroup = openGroup && openGroup !== 'wm' ? openGroup : null;
const status = busy;
return (
@@ -1069,39 +1114,33 @@ export function Workspace() {
<ToolRail tab={tab} onTab={setTab} />
<div className="chips">
{/* 1. ruler for the open slider */}
{slider ? <SliderRow slider={slider} /> : null}
{/* column 1 — the tab's own chips, RESET pinned at its head */}
<div className={`col col-main${tab === 'create' ? ' col-create' : ''}`} data-col="main">
<ChipRow chips={[resetChip()]} />
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
/>
) : (
<ChipRow chips={chips} />
)}
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
</div>
{/* 2. the open group's options */}
{openGroup && openGroup !== 'wm' ? (
<div className="strip">
<ChipRow chips={stripChips(openGroup)} />
</div>
) : null}
{/* 3. PRESETS -> RECIPES strip */}
{tab === 'presets' && showRecipes ? (
<div className="strip">
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
<ChipRow chips={recipeStrip} />
</div>
) : null}
{/* 4. CREATE -> the phone's recipe form */}
{tab === 'create' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
current={recipe.adjustments}
signedIn={!!user}
onPreview={previewCreatedRecipe}
onSave={(draft) => void addCreatedRecipe(draft)}
onExport={exportRecipeFile}
/>
) : null}
{/* 5. FRAME -> WATERMARK sub-panel: the mark's own text + place */}
{tab === 'frame' && isWmPanel(openGroup) ? (
<div className="strip wm-extra">
{/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */}
{wmOpen ? (
<div className="col col-sub strip wm-extra" data-col="wm">
<ChipRow
chips={[
{ key: 'wm-back', label: '< FRAME', onClick: () => setOpenGroup(null) },
...wmChips(),
]}
/>
{markOn ? (
<input
className="field"
@@ -1151,12 +1190,25 @@ export function Workspace() {
</div>
) : null}
{/* 6. main chip row, RESET pinned outside the scroll */}
<div className="main-row">
<ChipRow chips={[resetChip()]} />
<ChipRow chips={chips} />
</div>
{tab === 'favorited' && saved.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
{/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */}
{optGroup ? (
<div className="col col-sub strip" data-col="options">
<ChipRow chips={stripChips(optGroup)} />
</div>
) : null}
{tab === 'presets' && showRecipes ? (
<div className="col col-sub strip" data-col="recipes">
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
<ChipRow chips={recipeStrip} />
</div>
) : null}
{/* column 4 — the open ruler, sliders stacked */}
{slider ? (
<div className="col col-slider" data-col="slider">
<SliderRow slider={slider} />
</div>
) : null}
</div>
<ImageStage