diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 51f8fc0..ba63c69 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -2733,7 +2733,7 @@ export function Workspace() { }, // ponytail: no CREATE chip — making a recipe and filing the look on // the stage are their own rail tabs (SAVE RECENT, CREATE RECIPES). - // IMPORT is a chip inside the RECIPES strip. + // IMPORT is the chip under RECIPES in the tab's own column. ]; case 'favorited': if (user && !pro) return [verifyChip()]; @@ -2773,31 +2773,17 @@ export function Workspace() { return rows; } case 'light': - // 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. 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 close the WB panel - // (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: 'curve', - label: '∿ TONE CURVE', - active: openParam === 'curve', - amberValue: curveIsActive(recipe.adjustments.toneCurve), - onClick: () => toggleParam('curve'), - }, - ]; + // LIGHT is the one tab with no chip row: the sidebar (DevelopPanels) + // carries every pick of the tab — WB's presets, the tonal range and its + // curve at its foot, presence, detail and effects — so there is nothing + // left over for a row here. The four features that write rather than + // name — AUTO, FIX, GRADIENT MASK, MONOCHROME — are the TOOLS tab's, the + // film simulation is PRESETS', and the two Color Chromes close the WB + // panel (see chromeChips). A chip that opened a record the panel already + // shows would be a second way to one knob, which is why GRAIN has none + // either: its two knobs are the MONOCHROME GRAIN and GRAIN SIZE rows of + // DETAIL & EFFECTS. + return []; 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 @@ -2860,7 +2846,12 @@ export function Workspace() { onClick: () => toggleParam(key), }); return [ - { key: 'hsl-pick', label: 'PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } }, + { key: 'hsl-pick', label: '⌖ PICK', active: picking, onClick: () => { setBrushTool(null); setMaskTool(null); setPicking((v) => !v); } }, + // PICK is not a band: it reads a colour off the photo to aim the + // mixer, and every chip under the rule edits what it read, so the + // rule is where the row stops being the tool and starts being the + // colour. + { key: 'hsl-pick-rule', divider: true, label: '', onClick: () => {} }, ...HSL_BANDS.map((b): ChipDef => { const band = recipe.adjustments.hslBands?.[b.id]; const moved = !!band && (band[0] !== 0 || band[1] !== 0 || band[2] !== 0); @@ -2875,7 +2866,9 @@ export function Workspace() { }; }), // The divider keeps the three image-wide sliders out of the colour - // row: the row above picks a colour, these three move them all. + // row: the rule stands where the colour ends and the knobs that move + // every colour at once begin, and IMAGE names what they move. + { key: 'hsl-image-rule', divider: true, label: '', onClick: () => {} }, { key: 'hsl-image', label: 'IMAGE', disabled: true, onClick: () => {} }, knob('hsl.h', 'HUE', 0), knob('hsl.s', 'SAT', 1), @@ -3016,10 +3009,13 @@ export function Workspace() { // RECIPES = this account's own files (SAVE RECENT) + what this session made // but never filed + the bundled presets. A CREATE that a signed-in account // stored lands in `saved` too, so it is dropped from `created` by name here. - // IMPORT takes the .recipe file the phone (or another account) exports — same + // + // IMPORT is not one of them: it is not a look to open but the way a look gets + // into the list, so it stands in the tab's own column under the RECIPES chip + // that opens the list — the file the phone (or another account) exports, same // envelope, same parser, so a look crosses devices intact. + const importChip: ChipDef = { key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile }; const recipeStrip: ChipDef[] = [ - { key: 'recipes-import', label: 'IMPORT .RECIPE', onClick: pickRecipeFile }, ...saved.map((entry) => ({ key: `saved-${entry.id}`, label: entry.name, @@ -3054,9 +3050,9 @@ export function Workspace() { // effects. 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: ( - - ), + // STYLE is not a slot: the film simulation is PRESETS' own chip (see the + // `style` group), so a PROFILE panel here would be a second way to it. + // // 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. // @@ -3092,6 +3088,23 @@ export function Workspace() { // tracks above it, so they are read under the pair they are read against — // and on the tab whose colour they are. wbFoot: , + // TONE CURVE closes TONE: the graph on the photo is the shape of the range + // the panel's seven rows are, so it stands under them rather than in a row + // of its own at the top of the tab — and it glows amber once the graph is + // off the diagonal, which is the panel's own state read from its foot. + toneFoot: ( + toggleParam('curve'), + }, + ]} + /> + ), // D.RANGE closes DETAIL & EFFECTS in a box of its own: 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, and the four stops are one setting read as @@ -3257,10 +3270,9 @@ export function Workspace() { ) : ( <> - {/* LIGHT's column is the sidebar itself: the chip that is not - a knob, then every knob of the tab in one stack, all of - them folded away until one is asked for. */} - {tab === 'light' ? : null} + {/* LIGHT's column is the sidebar itself: every pick of the tab + is a panel of DevelopPanels, folded away until one is asked + for, so the tab opens no chip row of its own. */} {tab === 'light' ? ( ) : ( - + <> + + {/* IMPORT stands under the chip that opens the RECIPES + list: a look is imported INTO that list, so the way in + belongs to the chip, not to the list it fills. */} + {tab === 'presets' && showRecipes ? : null} + )} )} @@ -3435,7 +3453,7 @@ export function Workspace() { ) : null} {tab === 'presets' && showRecipes ? (
- {recipeStrip.length === 1 ?

{t('sec.savedEmpty')}

: null} + {recipeStrip.length === 0 ?

{t('sec.savedEmpty')}

: null}
) : null} diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 3a43260..9c29423 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -115,6 +115,18 @@ it apart from the tab's own buttons at the foot of the column. */ .reset-row { margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); } .chip-row { display: flex; flex-wrap: wrap; gap: 6px; } +/* The rule a strip is read across (the mixer's PICK, its bands, the three + knobs): a line of its own, so it breaks the row it is written into whichever + direction that row runs — a full width wraps it even in a phone's row-shaped + column, and `height: 1px` holds it to a line in the desktop column, where the + row's main axis is the other way round. */ +.chip-rule { + flex: 0 0 auto; + width: 100%; + height: 1px; + margin: 3px 0; + background: var(--border-soft); +} /* 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; } diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx index a6a6fae..19558e7 100644 --- a/docker/frontend/src/ui/ChipColumn.tsx +++ b/docker/frontend/src/ui/ChipColumn.tsx @@ -23,6 +23,11 @@ export interface ChipDef { // shows its state through the border alone. bg?: string; fg?: string; + // A rule instead of a button: the line the row is read across, between two + // sets that are not the same kind of thing (the mixer's PICK, the bands, the + // three image-wide knobs). It takes a line of its own and nothing else — the + // label it separates is the chip below it. + divider?: boolean; // Present when the chip itself can be dragged somewhere: the payload the drop // target reads back (the recipe's id, for the FAVORITED rail button). drag?: string; @@ -61,41 +66,45 @@ export interface SliderDef { export function ChipRow({ chips, grid }: { chips: ChipDef[]; grid?: boolean }) { return (
- {chips.map((chip) => ( - - ))} + {chips.map((chip) => + chip.divider ? ( + + ) : ( + + ) + )}
); } diff --git a/docker/frontend/src/ui/DevelopPanels.tsx b/docker/frontend/src/ui/DevelopPanels.tsx index 1d6208e..d5b39d3 100644 --- a/docker/frontend/src/ui/DevelopPanels.tsx +++ b/docker/frontend/src/ui/DevelopPanels.tsx @@ -24,21 +24,23 @@ const PANELS: { // 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 // D.RANGE selects close the column. - slot?: 'profile' | 'wb' | 'effects'; + slot?: 'wb' | 'effects'; // A pick that closes its panel rather than opening it: drawn under the rows, // where WB's two Color Chromes belong (they are read after the pair of tracks - // they are the other axis of). - foot?: 'wbFoot'; + // they are the other axis of) and where TONE's curve belongs, standing under + // the range it is the shape of. + foot?: 'wbFoot' | 'toneFoot'; slotFirst?: boolean; }[] = [ - // 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, foot: 'wbFoot' }, // 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: 'tone', + title: 'TONE', + rows: ['ev', 'exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'], + foot: 'toneFoot', + }, { slug: 'presence', title: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] }, { slug: 'effects', @@ -113,7 +115,7 @@ export function DevelopPanels({ set: (patch: Partial) => void; pro?: boolean; onPro?: () => void; - slots?: { profile?: ReactNode; wb?: ReactNode; wbFoot?: ReactNode; effects?: ReactNode }; + slots?: { wb?: ReactNode; wbFoot?: ReactNode; toneFoot?: ReactNode; effects?: ReactNode }; }) { const [open, setOpen] = useState>(() => { const saved = new Set((recall(OPEN_KEY) ?? '').split(','));