diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 2325f36..f32d0a0 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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; diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 0c9fd57..5a77217 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -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 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() {
- {/* 1. ruler for the open slider */} - {slider ? : null} + {/* column 1 — the tab's own chips, RESET pinned at its head */} +
+ + {tab === 'create' ? ( + void addCreatedRecipe(draft)} + onExport={exportRecipeFile} + /> + ) : ( + + )} + {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} +
- {/* 2. the open group's options */} - {openGroup && openGroup !== 'wm' ? ( -
- -
- ) : null} - - {/* 3. PRESETS -> RECIPES strip */} - {tab === 'presets' && showRecipes ? ( -
- {recipeStrip.length === 1 ?

{t('sec.savedEmpty')}

: null} - -
- ) : null} - - {/* 4. CREATE -> the phone's recipe form */} - {tab === 'create' ? ( - void addCreatedRecipe(draft)} - onExport={exportRecipeFile} - /> - ) : null} - - {/* 5. FRAME -> WATERMARK sub-panel: the mark's own text + place */} - {tab === 'frame' && isWmPanel(openGroup) ? ( -
+ {/* column 2 — FRAME's WATERMARK chip opens the mark's own panel */} + {wmOpen ? ( +
+ setOpenGroup(null) }, + ...wmChips(), + ]} + /> {markOn ? ( ) : null} - {/* 6. main chip row, RESET pinned outside the scroll */} -
- - -
- {tab === 'favorited' && saved.length === 0 ?

{t('sec.savedEmpty')}

: null} + {/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */} + {optGroup ? ( +
+ +
+ ) : null} + {tab === 'presets' && showRecipes ? ( +
+ {recipeStrip.length === 1 ?

{t('sec.savedEmpty')}

: null} + +
+ ) : null} + + {/* column 4 — the open ruler, sliders stacked */} + {slider ? ( +
+ +
+ ) : null}
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 .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; } } diff --git a/docker/frontend/src/styles/tokens.css b/docker/frontend/src/styles/tokens.css index 21f4e34..212e59f 100644 --- a/docker/frontend/src/styles/tokens.css +++ b/docker/frontend/src/styles/tokens.css @@ -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; diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 04286f8..bb75438 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -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 ? ( +
+ {slider.swatch} +
+ ) : null}
); diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx index d85700a..16af260 100644 --- a/docker/frontend/src/ui/ToolRail.tsx +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -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: '✎' },