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
+6 -4
View File
@@ -118,10 +118,12 @@ vec4 main(vec2 xy) {
// are untouched) and above it every channel keeps its ratio — the hue cannot
// drift, only the brightness blooms.
//
// Knee t0..t1 = 0.55..0.85: the soft end of the old hard 0.6 cut (2.5*0.6-1.5 =
// 0), wide enough that a specular ramps in instead of clipping on/off.
export const GLOW_T0 = 0.55;
export const GLOW_T1 = 0.85;
// Knee t0..t1 = 0.45..0.75. The web demo plays the effect up, so the knee sits
// lower and wider than the phone's 0.55..0.85: the bloom now catches the bright
// end of the midtones (a lit face, a window) instead of only true speculars,
// which is what makes it read on a photo that has no blown white.
export const GLOW_T0 = 0.45;
export const GLOW_T1 = 0.75;
export const GLOW_SKSL = `
uniform shader src;
+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
+5 -3
View File
@@ -375,10 +375,12 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
}
// 5b. HDF bright-pass bloom.
// 5b. HDF bright-pass bloom. The web demo shows the effect off, so both the
// blur radius and the screen alpha push further than the phone's: a wide
// halo around the highlights instead of a hairline glow.
if ((adjustments.hdf ?? 0) > 0) {
const strength = (adjustments.hdf ?? 0) / 10;
const sigma = width * (0.004 + 0.015 * strength);
const sigma = width * (0.006 + 0.024 * strength);
const { glowEffect: effect } = effects();
const srcShader =
paintShader ??
@@ -389,7 +391,7 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
const glowPaint = own(Skia.Paint());
glowPaint.setBlendMode(Skia.BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
glowPaint.setAlphaf(0.28 + 0.52 * strength);
if (glowShader != null) {
glowPaint.setShader(glowShader);
glowPaint.setImageFilter(
+63 -15
View File
@@ -62,27 +62,41 @@
.rail button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
.rail button:hover { color: var(--text); }
/* --- chip column -------------------------------------------------------- */
/* --- panel columns ------------------------------------------------------- */
/* The panel is a cascade: the rail holds the tabs, then one column per open
level — the tab's chips, the open chip's sub-chips, the open ruler. A child
column never hides the column it came from, so the path reads left to right. */
.chips {
width: var(--chip-w);
flex: 0 0 auto;
max-width: 62vw;
display: flex;
flex-direction: row;
align-items: stretch;
border-right: 1px solid var(--border-soft);
background: var(--bg);
overflow-y: auto;
padding: 10px;
overflow-x: auto;
overflow-y: hidden;
}
.col {
width: var(--col-w);
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px;
overflow-y: auto;
border-right: 1px solid var(--border-soft);
}
.chips h2 { margin: 0 0 2px; font-size: 11px; letter-spacing: 0.14em; color: var(--text-dim); font-weight: 600; }
.col:last-child { border-right: none; }
.col-slider { width: var(--slider-w); }
/* CREATE RECIPES draws a two-up form, so its column is the wide one. */
.col-create { width: 300px; }
.col-sub { background: var(--bg-elev); }
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
/* One strip at a time: the ruler, the open group's options, the recipe list and
the watermark fields each sit on their own row above the main chip row. */
.strip { border-top: 1px solid var(--border-soft); padding-top: 8px; display: flex; flex-direction: column; gap: 8px; }
/* RESET stays pinned at full size; the tab's chips take the rest and scroll. */
.main-row { display: flex; align-items: center; gap: 6px; min-width: 0; }
.main-row > .chip-row:last-child { flex: 1 1 auto; min-width: 0; }
.wm-extra { border-top-color: var(--accent-line); }
/* In a column the chips stack: one chip per row, full width. */
.col .chip-row { flex-direction: column; align-items: stretch; }
.col .chip { justify-content: space-between; text-align: left; }
.wm-extra { border-right-color: var(--accent-line); }
.chip {
border: 1px solid var(--border);
background: var(--bg-elev);
@@ -94,6 +108,7 @@
align-items: center;
gap: 6px;
}
.col-sub .chip { background: var(--bg); }
.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
/* Edited value, strip closed: the text glows amber, the border does not. */
.chip.amber, .chip.amber .val { color: var(--accent); }
@@ -101,14 +116,36 @@
.chip .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
/* The ruler row: "<" back button, label + live value, full-width slider. */
/* The ruler column: "<" back button, label + live value, slider, colour swatch
for the knobs that drive a hue (WB's TEMPERATURE / TINT). */
.slider-row { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; display: flex; align-items: center; gap: 10px; }
.col .slider-row { flex-direction: column; align-items: stretch; gap: 6px; }
.slider-row .ruler { flex: 1 1 auto; min-width: 0; }
.slider-row .head { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); }
.slider-row .head b { color: var(--text); font-family: var(--mono); letter-spacing: 0; }
.slider-row .head b.amber { color: var(--accent); }
.slider-row .swatch {
height: 26px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
}
.slider-row .swatch span {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
color: #fff;
mix-blend-mode: difference;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.slider-row .back {
flex: 0 0 auto;
align-self: flex-start;
border: 1px solid var(--border);
background: var(--bg-elev);
color: var(--text-dim);
@@ -299,7 +336,18 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
padding: 4px;
}
.rail button { flex: 1 0 auto; min-width: 62px; }
.chips { order: 1; width: auto; border-right: none; border-bottom: 1px solid var(--border-soft); max-height: 42vh; }
/* The cascade still runs left to right; the columns themselves scroll rather
than the page, so the stage keeps its width. */
.chips {
order: 1;
width: auto;
max-width: none;
max-height: 42vh;
border-right: none;
border-bottom: 1px solid var(--border-soft);
}
.col { width: 148px; padding: 8px; }
.col-slider { width: 200px; }
.col-create { width: 260px; }
.stage { order: 0; flex: 1 1 auto; }
.chip-row { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
}
+2 -1
View File
@@ -6,7 +6,8 @@
--radius: 10px;
--radius-sm: 7px;
--rail-w: 84px;
--chip-w: 300px;
--col-w: 168px;
--slider-w: 232px;
--font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-heading: var(--font);
--mono: ui-monospace, SFMono-Regular, Menlo, monospace;
+14
View File
@@ -27,6 +27,9 @@ export interface SliderDef {
display: string;
// amber value readout once the knob leaves its default (phone's ruler row)
edited: boolean;
// CSS colour the knob is currently driving (WB's temperature/tint), drawn as a
// swatch under the ruler so the hue moves with the number. Absent = no swatch.
swatch?: string;
onChange: (value: number) => void;
// "<" — closes the ruler and reopens the strip it was launched from.
onBack: () => void;
@@ -90,6 +93,17 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
value={slider.value}
onChange={(e) => slider.onChange(Number(e.target.value))}
/>
{slider.swatch ? (
<div
className="swatch"
data-key={`swatch-${slider.key}`}
data-color={slider.swatch}
style={{ background: slider.swatch }}
title={slider.swatch}
>
<span>{slider.swatch}</span>
</div>
) : null}
</div>
</div>
);
+6 -4
View File
@@ -1,15 +1,17 @@
import type { MsgKey } from '../i18n/vi';
import { useI18n } from '../i18n/I18nProvider';
// The app's six tabs, in the same order as the phone rail. The rail is vertical
// on desktop and becomes the bottom bar under 860px (see styles/app.css).
export type TabId = 'presets' | 'favorited' | 'light' | 'wb' | 'fx' | 'frame' | 'create';
// The app's seven tabs. WB leads LIGHT — white balance is the first thing the
// eye corrects, and the web rail draws them in the order the panel opens. The
// rail is a single column on desktop and becomes the bottom bar under 860px
// (see styles/app.css).
export type TabId = 'presets' | 'favorited' | 'wb' | 'light' | 'fx' | 'frame' | 'create';
export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'presets', label: 'tab.presets', glyph: '◉' },
{ id: 'favorited', label: 'tab.favorited', glyph: '★' },
{ id: 'light', label: 'tab.light', glyph: '☀' },
{ id: 'wb', label: 'tab.wb', glyph: '◐' },
{ id: 'light', label: 'tab.light', glyph: '☀' },
{ id: 'fx', label: 'tab.fx', glyph: '✦' },
{ id: 'frame', label: 'tab.frame', glyph: '▣' },
{ id: 'create', label: 'tab.create', glyph: '✎' },