diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 308bda9..5d6f4a2 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -81,6 +81,18 @@ assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back'); assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back'); assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again'); +// The rebuild after the ramp: the chroma difference rides the new luma, scaled +// by one number that only the cube can pull back. The doc's ratio is NOT it any +// more — scaling all three channels by Luma_new / Luma_old is exact until a +// channel clips, and a clipped channel is a moved hue (a skin tone at 24.0° came +// back at 48.0° at HIGHLIGHT +100, scratchpad hl-variants.mjs), and under L=0.5 +// it multiplies a near-black pixel's cast by up to x30. +assert.match(sksl, /float k = 1\.0;/); +assert.match(sksl, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/); +assert.match(sksl, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/); +assert.match(sksl, /rgb = clamp\(vec3\(o\) \+ \(rgb - vec3\(t\)\) \* k, 0\.0, 1\.0\);/); +assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back'); +assert.doesNotMatch(sksl, /max\(t, 0\.0004\)/, 'the luma ratio came back'); // The transfer pair has to be the accurate one where it is still used (the // exposure pass), or that pass is drawn in a space that is not linear at all. assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/); @@ -238,4 +250,76 @@ for (const bl of [-1, 1]) assert.ok(toe <= head + 1e-12, `toe ${toe} over head ${head}`); } +// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own +// chroma difference, and the one scale the cube allows. +const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]); +function rebuild(rgb, k) { + const t = lumaOf(rgb); + const o = ramp(t, k).o; + const hiC = Math.max(...rgb); + const loC = Math.min(...rgb); + let s = 1; + if (hiC > t) s = Math.min(s, (1 - o) / (hiC - t)); + if (loC < t) s = Math.min(s, o / (t - loC)); + const out = rgb.map((c) => o + (c - t) * s); + return { out, clamped: out.map((c) => clamp01(c)), o, t }; +} +function hueOf(c) { + const mx = Math.max(...c), mn = Math.min(...c), d = mx - mn; + if (d < 1e-9) return NaN; + let h; + if (mx === c[0]) h = (c[1] - c[2]) / d + (c[1] < c[2] ? 6 : 0); + else if (mx === c[1]) h = (c[2] - c[0]) / d + 2; + else h = (c[0] - c[1]) / d + 4; + return ((h * 60) % 360 + 360) % 360; +} +const colourCases = [ + [0.9, 0.72, 0.6], // skin — the case that moved 24° under the ratio + [1, 0.97, 0.92], // a warm white at the very top of the ramp + [0.45, 0.65, 0.9], // sky + [1, 0.6, 0.2], // orange, one channel already on the ceiling + [0.45, 0.85, 0.4], // green + [0.05, 0.03, 0.02], // a shadow with a cast + [0.01, 0.008, 0.006],// and the same cast with almost no light on it at all +]; +const greyCases = [[0.1, 0.1, 0.1], [0.5, 0.5, 0.5], [0.7, 0.7, 0.7], [0.9, 0.9, 0.9], [0.97, 0.97, 0.97]]; +const knobSets = []; +for (const hl of [-1, -0.5, 0, 0.5, 1]) + for (const wh of [-1, 0, 1]) + for (const sh of [-1, 0, 1]) + for (const bl of [-1, 0, 1]) knobSets.push({ hl, wh, sh, bl }); +for (const k of knobSets) { + for (const rgb of colourCases) { + const { out, clamped, o } = rebuild(rgb, k); + // The clamp is never what saves the pixel: the scale already landed the + // result inside the cube, which is the whole point of it. + for (let i = 0; i < 3; i++) + assert.ok(Math.abs(out[i] - clamped[i]) < 1e-12, `the cube clipped ${i} of ${rgb} at ${JSON.stringify(k)}`); + // Hue cannot move: every channel difference is scaled by the same number. + const dh = hueOf(clamped) - hueOf(rgb); + assert.ok(Number.isNaN(dh) || Math.abs(dh) < 1e-9, `hue moved ${dh} for ${rgb} at ${JSON.stringify(k)}`); + // ...and the new luma is the ramp's, exactly (the differences sum to zero + // in this weighting, so the scale drops out of the luma). + close(lumaOf(clamped), o, `luma ${rgb} at ${JSON.stringify(k)}`); + } + // A grey is a grey: no difference to carry, so it lands on the ramp value and + // picks up no cast on the way. + for (const rgb of greyCases) { + const { clamped, o } = rebuild(rgb, k); + for (const c of clamped) close(c, o, `grey drifted at ${JSON.stringify(k)}`); + } +} +// Every knob on zero is the identity for the colour too, not just the luma. +for (const rgb of [...colourCases, ...greyCases]) { + const { clamped } = rebuild(rgb, {}); + for (let i = 0; i < 3; i++) close(clamped[i], rgb[i], 'the colour rebuild is not the identity at zero'); +} +// The chroma is CARRIED, not re-scaled: where the cube has room, the channel +// differences come out as they went in (this is where the doc's ratio diverged +// — it multiplied them by Luma_new / Luma_old, which over-saturates a lift and +// drains a pull in the half of the ramp above L = 0.5, the highlight zone). +const carried = rebuild([0.7, 0.55, 0.45], { hl: 0.5 }).clamped; +const grew = (carried[0] - carried[1]) / (0.7 - 0.55); +close(grew, 1, 'the chroma was re-scaled on a highlight lift'); + console.log('highlight-knee-check ok'); diff --git a/docker/frontend/shared/utils/paramDefs.ts b/docker/frontend/shared/utils/paramDefs.ts index 140d5c8..f62d550 100644 --- a/docker/frontend/shared/utils/paramDefs.ts +++ b/docker/frontend/shared/utils/paramDefs.ts @@ -22,12 +22,26 @@ export interface ContinuousParamDef { const sign = (v: number) => (v > 0 ? `+${v}` : String(v)); const oneEv = (v: number) => `${v > 0 ? '+' : ''}${v.toFixed(1)} EV`; +// The bipolar develop knobs read -100..+100: the spec's own numbers (the panel +// draws every TONE and PRESENCE slider from -100 to 100, and the tone-mapping doc +// normalises that /100 on the way to its uniforms). The recipe keeps the ±10 unit +// every look has always been filed in, so the ×10 belongs to the slider and not +// to the store: a look saved, shared or shipped in DEFAULT_RECIPES before this +// still means exactly what it meant. A knob that is not bipolar — a temperature +// in kelvin, an exposure in stops, a grain size in percent, an amount that only +// goes up — keeps its own scale, because -100 of a percentage is nothing. +const HUNDRED = new Set(['contrast', 'color', 'vibrance', 'highlight', 'shadow', 'whites', 'blacks', 'tint', 'denoise', 'clarity', 'dehaze', 'sharpening']); +const deepen = (def: ContinuousParamDef): ContinuousParamDef => + HUNDRED.has(def.key) + ? { ...def, min: -100, max: 100, get: (a) => def.get(a) * 10, set: (v) => def.set(v / 10) } + : def; + export const PARAM_DEFS: { iq: ContinuousParamDef[]; wb: ContinuousParamDef[]; filters: ContinuousParamDef[]; } = { - iq: [ + iq: ([ { key: 'exposure', label: 'EXPOSURE', @@ -132,8 +146,8 @@ export const PARAM_DEFS: { get: (a) => a.blacks ?? 0, set: (v) => ({ blacks: v }), }, - ], - wb: [ + ] as ContinuousParamDef[]).map(deepen), + wb: ([ { key: 'temperature', label: 'COLOR TEMP', @@ -156,8 +170,8 @@ export const PARAM_DEFS: { get: (a) => a.tint ?? 0, set: (v) => ({ tint: v }), }, - ], - filters: [ + ] as ContinuousParamDef[]).map(deepen), + filters: ([ { key: 'denoise', label: 'NOISE REDUCTION', @@ -257,5 +271,5 @@ export const PARAM_DEFS: { get: (a) => a.vignette ?? 0, set: (v) => ({ vignette: v }), }, - ], + ] as ContinuousParamDef[]).map(deepen), }; diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 4ed9c4c..7b9bfa5 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -17,12 +17,11 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils'; // of the ramp — and the 0.50 midpoint is in none of them: it is the one value // every one of the four leaves where it was. // -// Colour: the luma takes the move and R, G, B keep their RATIO, which is the -// doc's `R_new = R_old * Luma_new / Luma_old`. One gain on all three channels -// carries the hue through with the chroma, so a shadow lifted under a warm -// light does not drift toward white. The `cg` clamp below is the one guard kept -// on the ratio: past 1.35 it blows a dark saturated colour to white, and below -// 0.55 it collapses a colour to black. +// Colour: the luma takes the move and R, G, B keep their DIFFERENCES — the +// pixel lands on its new luma with the chroma it had, so the hue is untouched +// and a grey stays grey. The doc reaches the same place with a ratio +// (`R_new = R_old * Luma_new / Luma_old`), which is exact while it fits and +// moves the hue the moment a channel clips; see the note on `k` below. // // These four masks are ADDED in the doc's own pseudo-shader, and measured that // way the ramp inverts: BLACK +10 against SHADOW -10 falls to a slope of -5 per @@ -221,11 +220,31 @@ vec4 main(vec2 xy) { o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t)); o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t)); o = clamp(o, 0.0, 1.0); - // Lightness takes the curve, hue stays: the colour difference is gained - // only part-way so darkening cannot collapse a colour to black and lifting - // cannot blow a dark saturated colour out to white. - float cg = clamp(o / max(t, 0.0004), 0.55, 1.35); - rgb = clamp(vec3(o) + (rgb - vec3(t)) * cg, 0.0, 1.0); + // Lightness takes the curve; the colour rides the difference. The pixel moves + // to its new luma and carries its own chroma with it — the three channel + // differences are scaled by ONE number, so the hue cannot move and a grey + // cannot pick up a cast (a neutral has no difference to carry, and lands on + // o exactly). + // + // The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading + // of the same sentence, and it is what this pass used to do. It is exact — + // until the result stops fitting. Past 1.0 a channel clips, the differences + // stop being scaled together, and the hue goes with them: measured on the + // scratchpad probe (hl-variants.mjs), a skin tone at 24.0° came back at 48.0° + // at HIGHLIGHT +100, and a warm white at 37° at 57.4°. Under L = 0.5 the same + // ratio also multiplies whatever cast a near-black pixel had — x30 on a + // shadow with a hair of warmth, which is colour noise amplified, the reason + // the old arbitrary 0.55..1.35 clamp was there. + // + // So the scale is the chroma's own (1.0) and the only thing that pulls it back + // is the cube: a pixel with no room left gives up saturation instead of hue, + // and one that the curve has actually driven to 1.0 arrives at white. + float k = 1.0; + float hiC = max(max(rgb.r, rgb.g), rgb.b); + float loC = min(min(rgb.r, rgb.g), rgb.b); + if (hiC > t) k = min(k, (1.0 - o) / (hiC - t)); + if (loC < t) k = min(k, o / (t - loC)); + rgb = clamp(vec3(o) + (rgb - vec3(t)) * k, 0.0, 1.0); // Split tone (stock look): the shadows and the highlights may each carry // their own tint, so the two ends of the curve can drift opposite ways // (Classic Neg: green-cyan darks, warm brights) without touching mid-greys. diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 0029d6a..2cbd0ab 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -26,7 +26,7 @@ import { preloadSuperRes, upscaleJpeg } from './engine/superRes'; import { api, type Photo, type SavedRecipe, type User } from './api'; import { TIERS } from './config/tiers'; import { DEFAULT_ADJUSTMENTS, DEFAULT_RECIPES, FILM_SIMS, filmSimToRecipe } from '../shared/utils/defaultRecipes'; -import { PARAM_DEFS, type ContinuousParamDef } from '../shared/utils/paramDefs'; +import { PARAM_DEFS } from '../shared/utils/paramDefs'; import { exportRecipeXml, importRecipeXml } from '../shared/utils/recipeShare'; import { writeJpegExif } from '../shared/utils/exifWrite'; import { @@ -172,11 +172,10 @@ function hslToHex(h: number, s: number, l: number): string { } // 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. +// visible next to the number. TEMP is the only one left on this path: TINT's row +// lives in the sidebar's WB panel now, so its ±10 axis draws no ruler here. const SLIDER_SWATCH: Partial string>> = { temperature: temperatureSwatch, - 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 @@ -2038,24 +2037,6 @@ export function Workspace() { // Continuous parameter chips: label + live value; amber text when edited, // amber border while its ruler is open. - const paramChips = (defs: ContinuousParamDef[]): ChipDef[] => - defs.map((def) => { - const value = def.get(recipe.adjustments); - const locked = !!def.pro && !pro; - return { - key: def.key, - label: def.label, - value: def.display(value), - active: openParam === def.key, - amberValue: openParam !== def.key && value !== def.defaultValue, - // The phone greys a PRO knob out; the web keeps it clickable and says - // why instead, because the fix here is an action (prove the address) - // rather than a purchase. - pro: !!def.pro, - onClick: locked ? promptPro : () => toggleParam(def.key), - }; - }); - const slider: SliderDef | null = useMemo(() => { if (!openParam) return null; // STRAIGHTEN is not a knob either: it is FRAME's own angle, and it is not @@ -2184,13 +2165,6 @@ export function Workspace() { if (remembered && remembered.kelvin === t && remembered.tint === ti) return remembered.key; return WB_PRESETS.find((w) => w.kelvin === t && w.tint === ti)?.key ?? `${t}K`; }; - // TEMP is the one strip chip that names its value, and it names it always: - // TEMP AUTO at the neutral pair, TEMP SHADE on a preset, TEMP 6300K where a - // hand-dragged ruler landed. - const wbLabel = (): string => { - const v = wbValue(); - return WB_PRESETS.find((w) => w.key === v)?.label ?? v; - }; // GRAIN's readout: the clump count the stock and the SIZE knob add up to, in // the print's own terms (grainPerInch, grainShader.ts). A READOUT, not a pick — // the strip prints it beside the two knobs that move it. The field swings a @@ -2493,22 +2467,24 @@ export function Workspace() { // shoulder, not a gain on the whole pixel), and the two spatial ones — CLARITY // against the frame's blurred reference, DEHAZE through the dark channel. Each // one means inside the mask what the row of the same name means on the whole - // frame. All six are the same -10..+10 row, so one factory builds them. + // frame, down to the -100..+100 the frame-wide rows read (the mask keeps the + // look's own ±10 unit, and the ×10 is the slider's — see paramDefs). const maskKnobRow = ( key: 'highlights' | 'shadows' | 'whites' | 'blacks' | 'clarity' | 'dehaze', label: string ): SliderDef => { const v = selMask?.[key] ?? 0; + const wide = Math.round(v * 10); return { key: `mask-${key}`, label, - value: v, - min: -10, - max: 10, + value: wide, + min: -100, + max: 100, step: 1, - display: v > 0 ? `+${v}` : String(v), + display: wide > 0 ? `+${wide}` : String(wide), edited: v !== 0, - onChange: (n: number) => setMaskKnob({ [key]: Math.round(n) } as Partial), + onChange: (n: number) => setMaskKnob({ [key]: Math.round(n) / 10 } as Partial), onReset: () => setMaskKnob({ [key]: 0 } as Partial), }; }; @@ -2531,25 +2507,25 @@ export function Workspace() { { key: 'mask-contrast', label: 'CONTRAST', - value: selMask.contrast, - min: -10, - max: 10, + value: Math.round(selMask.contrast * 10), + min: -100, + max: 100, step: 1, - display: selMask.contrast > 0 ? `+${selMask.contrast}` : String(selMask.contrast), + display: selMask.contrast > 0 ? `+${Math.round(selMask.contrast * 10)}` : String(Math.round(selMask.contrast * 10)), edited: selMask.contrast !== 0, - onChange: (v) => setMaskKnob({ contrast: Math.round(v) }), + onChange: (v) => setMaskKnob({ contrast: Math.round(v) / 10 }), onReset: () => setMaskKnob({ contrast: 0 }), }, { key: 'mask-saturation', label: 'SATURATION', - value: selMask.saturation, - min: -10, - max: 10, + value: Math.round(selMask.saturation * 10), + min: -100, + max: 100, step: 1, - display: selMask.saturation > 0 ? `+${selMask.saturation}` : String(selMask.saturation), + display: selMask.saturation > 0 ? `+${Math.round(selMask.saturation * 10)}` : String(Math.round(selMask.saturation * 10)), edited: selMask.saturation !== 0, - onChange: (v) => setMaskKnob({ saturation: Math.round(v) }), + onChange: (v) => setMaskKnob({ saturation: Math.round(v) / 10 }), onReset: () => setMaskKnob({ saturation: 0 }), }, maskKnobRow('highlights', 'HIGHLIGHT'), @@ -2757,12 +2733,16 @@ export function Workspace() { } 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. + // (DevelopPanels) puts every one of them on screen at once — WB's two, + // the tonal range, presence, detail and effects — so what stays in this + // row is what is NOT a knob. AUTO, which is an action (see autoTune); + // TONE CURVE, which opens the graph on the photo (ToneCurvePanel) rather + // than a ruler in the last column, and glows amber once the graph is off + // the diagonal; and the three tools that take the pointer or a strip of + // their own — FIX's brushes, GRADIENT MASK's shapes, MONOCHROME and the + // GRAIN strip. WB and FX are panels of this tab now, so their rows live + // here: a chip that opened the same record the panel already shows would + // be a second way to one knob. return [ { key: 'auto', label: 'AUTO', onClick: () => void autoTune() }, { @@ -2772,27 +2752,10 @@ export function Workspace() { amberValue: curveIsActive(recipe.adjustments.toneCurve), onClick: () => toggleParam('curve'), }, - ]; - case 'wb': - return [ - { - // TEMP is the one strip chip that names its value — nothing else - // does, and it names it even at the neutral pair: TEMP AUTO. - ...groupChip('temp'), - label: 'TEMP', - value: wbLabel(), - }, - ...paramChips(PARAM_DEFS.wb.filter((p) => p.key !== 'temperature')), - groupChip('cx'), - groupChip('cxb'), - ]; - case 'fx': { - // FX's tools are not looks: each is a strip of its own, opened from the - // row and holding the two chips that arm the pointer — FIX's brushes and - // GRADIENT MASK's shapes. The strip's own chip carries the name and glows - // amber while there is something inside it to clear, so the row above the - // look chips stays a row of looks. - return [ + // FX's tools are not looks: each is a strip of its own, opened from the + // row. FIX repairs a speck of dust, MOSAIC hides a patch, GRADIENT MASK + // draws a shape; the strip's own chip carries the name and glows amber + // while there is something inside it to clear. { key: 'fix', label: 'FIX', @@ -2818,13 +2781,10 @@ export function Workspace() { }, { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, // GRAIN is a strip of its own — amount, size, and the count they add up - // to — so it is one chip here and its two knobs live inside it; the - // chip keeps the place the knob had in this row. - ...PARAM_DEFS.filters.flatMap((p): ChipDef[] => - p.key === 'grain' ? [groupChip('grain')] : p.key === 'grainSize' ? [] : paramChips([p]) - ), + // to — so it is one chip here and its two knobs are rows of DETAIL & + // EFFECTS. + groupChip('grain'), ]; - } case 'hsl': { // The mixer is PRO: the picker, the eight bands and the three // image-wide knobs all come with the account, so a guest is offered the diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index faf9987..8b0bedc 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -58,8 +58,6 @@ export const en: Dict = { 'tab.favorited': 'FAVORITED', 'tab.photos': 'MY PHOTOS', 'tab.light': 'LIGHT', - 'tab.wb': 'WB', - 'tab.fx': 'FX', 'tab.hsl': 'HSL', 'hsl.hint': 'PRESS PICK, THEN SAMPLE A COLOUR ON THE PHOTO', 'tab.frame': 'FRAME', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index adbcb14..bd44dd3 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -71,8 +71,6 @@ export const vi = { 'tab.favorited': 'FAVORITED', 'tab.photos': 'ẢNH CỦA TÔI', 'tab.light': 'LIGHT', - 'tab.wb': 'WB', - 'tab.fx': 'FX', 'tab.hsl': 'HSL', 'hsl.hint': 'BẤM PICK RỒI CHỌN MỘT MÀU TRÊN ẢNH', 'tab.frame': 'FRAME', diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx index b3c9e9f..13ac66d 100644 --- a/docker/frontend/src/ui/ToolRail.tsx +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -1,11 +1,12 @@ import type { MsgKey } from '../i18n/vi'; import { useI18n } from '../i18n/I18nProvider'; -// The app's eight 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' | 'photos' | 'wb' | 'light' | 'fx' | 'hsl' | 'frame' | 'save' | 'create'; +// The app's eight tabs. LIGHT is the develop column and holds every knob of the +// picture — white balance, the tonal range, presence and the effects — so WB and +// FX, which used to be tabs of their own, are panels of it now. The rail is a +// single column on desktop and becomes the bottom bar under 860px (see +// styles/app.css). +export type TabId = 'presets' | 'favorited' | 'photos' | 'light' | 'hsl' | 'frame' | 'save' | 'create'; // The build this bundle came from, put in by vite at build time and named by // whoever built the image (see vite.config.ts). Empty under a dev server. @@ -15,9 +16,7 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'presets', label: 'tab.presets', glyph: '◉' }, { id: 'favorited', label: 'tab.favorited', glyph: '★' }, { id: 'photos', label: 'tab.photos', glyph: '▤' }, - { id: 'wb', label: 'tab.wb', glyph: '◐' }, { id: 'light', label: 'tab.light', glyph: '☀' }, - { id: 'fx', label: 'tab.fx', glyph: '✦' }, // The HSL mixer sits with the colour tabs: it edits one hue band at a time. { id: 'hsl', label: 'tab.hsl', glyph: '◍' }, { id: 'frame', label: 'tab.frame', glyph: '▣' },