diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 24485e0..64b666c 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2352,20 +2352,32 @@ export function Workspace() { }; // A strip chip names its pick on the right edge, spelled the way the strip's - // own option spells it. Still on its neutral value -> nothing to name. - const groupChip = (g: Exclude): ChipDef => { + // own option spells it. Still on its neutral value -> nothing to name — unless + // the caller says the chip always names one, which the two Color Chromes do: + // the pair is read against each other, and OFF is one of the two values being + // compared. + const groupChip = (g: Exclude, nameAlways = false): ChipDef => { const { label, value, off, options } = groupDefs[g]; const picked = options.find((o) => o.v === value); return { key: `grp-${g}`, label, - value: value !== off ? picked?.d ?? value : undefined, + value: value !== off || nameAlways ? picked?.d ?? value : undefined, active: openGroup === g, amberValue: openGroup !== g && value !== off, onClick: () => toggleGroup(g), }; }; + // The two Color Chromes, side by side in LIGHT's own column: one row of two + // chips, each naming the strength it is on. They are a pair, not a strip — + // COLOR CHROME and CHROME BLUE are the same switch over two axes of the same + // colour, so they are read against each other rather than scanned in turn, and + // they wear the table layout the WB presets wear (see .chip-row.grid / + // ChipRow's `grid`). Either one opens its own strip of values in the last + // column, the same strip it has always been (see stripChips). + const chromeChips: ChipDef[] = [groupChip('cx', true), groupChip('cxb', true)]; + // The pair a chip carries when its option is a whole look: the photo takes the // look while the pointer rests on the chip, and gives it back when it leaves. const lookPreview = (look: Recipe): Pick => ({ @@ -2764,33 +2776,48 @@ export function Workspace() { // LIGHT is the one tab whose knobs are not chips: the sidebar // (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 — FIX's - // brushes, GRADIENT MASK's shapes, MONOCHROME. 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. + // row is what is NOT a knob. Only TONE CURVE is left: it 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. It stands over the + // TONE panel, whose knobs it is the shape of. The four features that + // write rather than name — AUTO, FIX, GRADIENT MASK, MONOCHROME — are + // the TOOLS tab's now, and the two Color Chromes are the pair of chips + // under this row (see chromeChips). 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. // // GRAIN has no chip of its own for that reason: its two knobs are the // MONOCHROME GRAIN and GRAIN SIZE rows of DETAIL & EFFECTS below, and the // chip that opened a strip of the same two was a second way to them. return [ - { key: 'auto', label: 'AUTO', onClick: () => void autoTune() }, { key: 'curve', - label: 'TONE CURVE', + label: '∿ TONE CURVE', active: openParam === 'curve', amberValue: curveIsActive(recipe.adjustments.toneCurve), onClick: () => toggleParam('curve'), }, + ]; + case 'tools': + // TOOLS is the tab of the features that write rather than name: AUTO + // reads the loaded photo and lands a whole set of numbers on it (see + // autoTune), FIX and GRADIENT MASK take the pointer, MONOCHROME swaps the + // base filter for the mono stock. They are one kind of thing — a move + // made on the frame, not a look picked for it — which is why they share + // a tab instead of LIGHT's row. Each carries its own mark at the head of + // its label so the four read as one set. + // + // Nothing here is a knob, so the tab is this row alone: the strip a chip + // opens is the last column's (see stripChips), the same as anywhere else. + return [ + { key: 'auto', label: '✦ AUTO', onClick: () => void autoTune() }, // 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', + label: '✚ FIX', active: openGroup === 'fix', amberValue: healSpots.length > 0 || mosaicSpots.length > 0, onClick: () => { @@ -2802,7 +2829,7 @@ export function Workspace() { }, { key: 'gradient-mask', - label: 'GRADIENT MASK', + label: '▚ GRADIENT MASK', active: openGroup === 'gradient', amberValue: masks.length > 0, onClick: () => { @@ -2811,7 +2838,7 @@ export function Workspace() { toggleGroup('gradient'); }, }, - { key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono }, + { key: 'mono', label: '◑ MONOCHROME', active: monoOn, onClick: toggleMono }, ]; case 'hsl': { // The mixer is PRO: the picker, the eight bands and the three @@ -3022,18 +3049,14 @@ export function Workspace() { // The studio sidebar's three slots (DevelopPanels): the picks that name a // whole look rather than a number, so they stay strips of their own inside the - // panel that owns them — SIM and D.RANGE with the tone controls, the WB - // presets with the two WB tracks, the two Color Chromes at the foot of the - // effects column. Every one of them is the strip that already existed, so a - // pick made here is the pick made on the tab it came from. + // panel that owns them — SIM with the profile, the WB presets with the two WB + // tracks, D.RANGE at the foot of the effects. The two Color Chromes are the one + // pair that does not live in a panel: they are LIGHT's own row (see + // chromeChips). Every one of them is the strip that already existed, so a pick + // made here is the pick made on the tab it came from. const developSlots = { profile: ( - <> - - - + ), // COLOR TEMP is left out: the ruler is the panel's own TEMPERATURE row, so a // chip that opened the same ruler would be a second way to the same knob. @@ -3066,11 +3089,11 @@ export function Workspace() { }))} /> ), + // D.RANGE closes DETAIL & EFFECTS: it is a hold on the whole frame, so it + // stands at the foot of the effects rather than with the tone knobs it is + // read against. effects: ( - <> - - - + ), }; @@ -3227,10 +3250,11 @@ export function Workspace() { ) : ( <> - {/* LIGHT's column is the sidebar itself: the two chips that are - not knobs, then every knob of the tab in one stack, all of - them open at once. */} + {/* LIGHT's column is the sidebar itself: the chip that is not + a knob, the pair of Color Chromes, then every knob of the tab + in one stack, all of them open at once. */} {tab === 'light' ? : null} + {tab === 'light' ? : null} {tab === 'light' ? ( +
{} }, diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 2465f80..c756b4d 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -58,6 +58,7 @@ export const en: Dict = { 'tab.favorited': 'FAVORITED', 'tab.photos': 'MY PHOTOS', 'tab.light': 'LIGHT', + 'tab.tools': 'TOOLS', '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 2dac607..9341e74 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -71,6 +71,7 @@ export const vi = { 'tab.favorited': 'FAVORITED', 'tab.photos': 'ẢNH CỦA TÔI', 'tab.light': 'LIGHT', + 'tab.tools': 'TOOLS', '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/styles/app.css b/docker/frontend/src/styles/app.css index 201c01b..e4b6f89 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -219,8 +219,13 @@ .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; } +/* The same one-line ruler as the sidebar's rows: the knob's name, its track, + then the number it is on — and a colour swatch under them where the knob + drives a hue (WB's TEMPERATURE / TINT). */ +.slider-row .head { display: flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); } +.slider-row .head span { flex: 0 0 auto; } +.slider-row .head input[type="range"] { flex: 1 1 40px; min-width: 0; } +.slider-row .head b { flex: 0 0 auto; color: var(--text); font-family: var(--mono); letter-spacing: 0; } .slider-row .head b.amber { color: var(--accent); } .slider-row .swatch { height: 26px; @@ -693,9 +698,20 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } .hsl-card-close { border: 1px solid var(--border); background: var(--bg); color: var(--text-dim); border-radius: 999px; padding: 2px 7px; font-size: 11px; cursor: pointer; } .hsl-card-knobs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } .mini-slider { min-width: 0; } -.mini-head { display: flex; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); } -.mini-head b { font-family: var(--mono); letter-spacing: 0; color: var(--text); } +.mini-head { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); } +.mini-head span { flex: 0 0 auto; } +/* The track takes the width the two labels leave, so the name of the knob opens + the row and the number it is on closes it (see MiniSlider). */ +.mini-head input[type="range"] { flex: 1 1 40px; min-width: 0; } +.mini-head b { flex: 0 0 auto; font-family: var(--mono); letter-spacing: 0; color: var(--text); } .mini-head b.amber { color: var(--accent); } +/* The mixer's three knobs share one narrow panel, so they keep the stacked form + the row above has just left: the name and its number on one line, the track + under them. The readout is ordered ahead of the track, which takes the whole + second line — in the markup the track sits between the two. */ +.hsl-card-knobs .mini-head { flex-wrap: wrap; justify-content: space-between; } +.hsl-card-knobs .mini-head b { order: 1; } +.hsl-card-knobs .mini-head input[type="range"] { order: 2; flex: 1 0 100%; } /* FRAME's STRAIGHTEN scale: a graduated ruler lying along the foot of the photo. The layer is transparent to the pointer so the photo still answers a drag outside the card; the card itself takes the drag. */ diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index 105c659..a6a6fae 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -56,7 +56,8 @@ export interface SliderDef { } // `grid` lays the row out as the table a set of same-kind values is read as -// (WB's presets) instead of the wrapping strip the option rows use. +// (WB's presets, LIGHT's two Color Chromes) instead of the wrapping strip the +// option rows use. export function ChipRow({ chips, grid }: { chips: ChipDef[]; grid?: boolean }) { return (
@@ -128,19 +129,23 @@ export function SliderRow({ slider }: { slider: SliderDef }) { ) : null}
+ {/* Label, track, readout: one line, the way a ruler is read — the name + of the knob at the head of the row, the number it is on at the foot, + and the travel between them taking the width that is left. */}
{slider.label} + slider.onChange(Number(e.target.value))} + onDoubleClick={slider.onReset} + /> {slider.display}
- slider.onChange(Number(e.target.value))} - onDoubleClick={slider.onReset} - /> {slider.swatch ? (
+ {/* The studio's rows are one line each, the way a ruler is read: the + knob's name, the track, then the number it is on. The mixer's three + knobs are the exception — they sit three to a narrow panel, where a + track between two labels would be a sliver, so they stack (see + .hsl-card-knobs in app.css). */}
{label} + onChange(Number(e.target.value))} + onDoubleClick={onReset} + /> {format ? format(value) : value > 0 ? `+${value}` : String(value)}
- onChange(Number(e.target.value))} - onDoubleClick={onReset} - />
); } diff --git a/docker/frontend/src/ui/DevelopPanels.tsx b/docker/frontend/src/ui/DevelopPanels.tsx index 33405c1..e5435aa 100644 --- a/docker/frontend/src/ui/DevelopPanels.tsx +++ b/docker/frontend/src/ui/DevelopPanels.tsx @@ -23,13 +23,18 @@ const PANELS: { rows: string[]; // A slot rides inside its panel, where the parent's own select belongs in the // reading order — before the panel's knobs except in DETAIL & EFFECTS, whose - // COLOR CHROME selects close the column. + // D.RANGE selects close the column. slot?: 'profile' | 'wb' | 'effects'; slotFirst?: boolean; }[] = [ - { slug: 'profile', title: 'PROFILE', rows: ['ev'], slot: 'profile', slotFirst: true }, + // PROFILE is the film simulation and nothing else: EV's ruler is a knob of the + // TONE panel's, the range the picture is on, so it sits with the rest of them + // and not with the stock the frame is shot on. + { slug: 'profile', title: 'PROFILE', rows: [], slot: 'profile', slotFirst: true }, { slug: 'wb', title: 'WB', rows: ['temperature', 'tint'], slot: 'wb', slotFirst: true }, - { slug: 'tone', title: 'TONE', rows: ['exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'] }, + // EV leads TONE: it is the one number the whole range hangs off, and the + // exposure below it is the finer move on the same axis. + { slug: 'tone', title: 'TONE', rows: ['ev', 'exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'] }, { slug: 'presence', title: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] }, { slug: 'effects', diff --git a/docker/frontend/src/ui/ToolRail.tsx b/docker/frontend/src/ui/ToolRail.tsx index 13ac66d..6963c3b 100644 --- a/docker/frontend/src/ui/ToolRail.tsx +++ b/docker/frontend/src/ui/ToolRail.tsx @@ -1,12 +1,14 @@ import type { MsgKey } from '../i18n/vi'; import { useI18n } from '../i18n/I18nProvider'; -// The app's eight tabs. LIGHT is the develop column and holds every knob of the +// The app's nine 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'; +// FX, which used to be tabs of their own, are panels of it now. TOOLS is the one +// tab of features rather than records: the chips that write a value or take the +// pointer (AUTO, FIX, GRADIENT MASK, MONOCHROME), which used to share LIGHT's +// row with the two chips that are not knobs. 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' | 'tools' | '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. @@ -17,6 +19,9 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [ { id: 'favorited', label: 'tab.favorited', glyph: '★' }, { id: 'photos', label: 'tab.photos', glyph: '▤' }, { id: 'light', label: 'tab.light', glyph: '☀' }, + // The hammer and pick: the features that work ON the photo — AUTO's reading, + // FIX's brushes, GRADIENT MASK's shapes, the MONOCHROME switch. + { id: 'tools', label: 'tab.tools', 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: '▣' },