diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index b962b3f..9ae7690 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -197,15 +197,41 @@ const SLIDER_SWATCH: Partial string>> = { type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'grain' | 'wm' | 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont'; type ToolStrip = 'fix' | 'gradient'; -type StripKey = GroupKey | ToolStrip; +// LIGHT's four panels, as strips of their own: the phone reaches a knob through +// its panel (LIGHT -> WB -> COLOR TEMP), so each panel is a level of the same +// cascade the other tabs' chips already walk. The wide screen draws the same +// four as the column's disclosures (see ui/DevelopPanels). +type DevPanel = 'wb' | 'tone' | 'presence' | 'effects'; +type StripKey = GroupKey | ToolStrip | DevPanel; const WM_SUBGROUPS: StripKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont']; const isWmPanel = (g: StripKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); // A slider row opened from a strip returns to it with "<" (COLOR TEMP -> TEMP). -const PARAM_GROUP: Partial> = { - temperature: 'temp', +// Every knob LIGHT owns names the panel it was opened from, so its ruler's back +// reopens that panel's strip rather than dropping the strip off the screen. +const PARAM_GROUP: Partial> = { + temperature: 'wb', + tint: 'wb', + ev: 'tone', + exposure: 'tone', + contrast: 'tone', + highlight: 'tone', + shadow: 'tone', + whites: 'tone', + blacks: 'tone', + clarity: 'presence', + dehaze: 'presence', + vibrance: 'presence', + color: 'presence', + denoise: 'effects', + sharpening: 'effects', + vignette: 'effects', + hdf: 'effects', + grain: 'effects', + grainSize: 'effects', + // The curve is the graph on the photo, not a row: it opens no ruler, but the + // TONE strip it came from stays up while it is on. + curve: 'tone', straighten: 'rotate', - grain: 'grain', - grainSize: 'grain', }; const CHROME_OPTIONS = [ @@ -219,6 +245,32 @@ const DR_OPTIONS = [ { v: '200', d: 'DR200' }, { v: '400', d: 'DR400' }, ]; + +// LIGHT's stack as a strip reads it: the panel, its name, and the rows it owns — +// the same four DevelopPanels draws on a wide screen, in the same order. The +// picks that are not rows (WB's presets and chromes, TONE's curve, the D.RANGE +// stops) belong to a panel too, so stripChips adds them where the panel they +// close is; the table is only the rows. +const DEV_PANELS: { key: DevPanel; label: string; rows: string[] }[] = [ + { key: 'wb', label: 'WB', rows: ['temperature', 'tint'] }, + { + key: 'tone', + label: 'TONE', + rows: ['ev', 'exposure', 'contrast', 'highlight', 'shadow', 'whites', 'blacks'], + }, + { key: 'presence', label: 'PRESENCE', rows: ['clarity', 'dehaze', 'vibrance', 'color'] }, + { + key: 'effects', + label: 'DETAIL & EFFECTS', + rows: ['denoise', 'sharpening', 'vignette', 'hdf', 'grain', 'grainSize'], + }, +]; + +// One lookup for a panel's rows: a chip names the knob by its panel's own def, +// so a row the phone has and the web has not simply draws nothing. +const DEV_DEF = new Map( + [...PARAM_DEFS.iq, ...PARAM_DEFS.wb, ...PARAM_DEFS.filters].map((def) => [def.key, def]) +); const WM_COLORS = [ { v: '#ffffff', d: 'WHITE' }, { v: '#f59e0b', d: 'AMBER' }, @@ -2766,6 +2818,36 @@ export function Workspace() { } }; + // A panel's own row, as a chip: the knob by name, its live value under it, and + // a tap that opens its ruler in the row above the strip (the strip itself + // stays where it is, so the panel is still under the ruler — see .col-slider + // in styles/app.css). Amber text once the knob has left its default, amber + // border while its ruler is the one open, which is how every other chip in + // the app reads. + const devRow = (key: string): ChipDef => { + const def = DEV_DEF.get(key)!; + const value = def.get(recipe.adjustments); + // A PRO knob on an account without it is the upgrade path, exactly as the + // sidebar's locked row is: the chip is there and the tap explains. + const locked = !!def.pro && !pro; + return { + key, + label: def.label, + value: def.display(value), + active: openParam === key, + amberValue: openParam !== key && value !== def.defaultValue, + pro: locked || undefined, + onClick: locked ? promptPro : () => toggleParam(key), + }; + }; + + // A panel is worth a mark once one of its rows has moved. + const panelMoved = (panel: { rows: string[] }): boolean => + panel.rows.some((key) => { + const def = DEV_DEF.get(key)!; + return def.get(recipe.adjustments) !== def.defaultValue; + }); + // The main chip row of the active tab, exactly the phone's per-tab sets. const chips: ChipDef[] = (() => { switch (tab) { @@ -2822,17 +2904,25 @@ export function Workspace() { return rows; } case 'light': - // 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 []; + // LIGHT's chips ARE its four panels, and the panel's own strip is what + // opens next (see DEV_PANELS and stripChips): the phone walks + // LIGHT -> WB -> COLOR TEMP, one strip at a time. Every pick of the tab + // is inside the panel that owns it — WB's presets with WB, the curve + // with the range it is the shape of — so the row names the four and the + // strip does the rest. + // + // A wide screen reads the same four as the column's own disclosures + // (DevelopPanels) and hides this row: two ways to one knob is one too + // many (see .dev-chips in styles/app.css). + return DEV_PANELS.map( + (panel): ChipDef => ({ + key: `dev-${panel.key}`, + label: panel.label, + active: openGroup === panel.key, + amberValue: openGroup !== panel.key && panelMoved(panel), + onClick: () => toggleGroup(panel.key), + }) + ); 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 @@ -3029,11 +3119,51 @@ export function Workspace() { }; const withIcon = (c: ChipDef): ChipDef => (c.divider || c.disabled ? c : { ...c, icon: chipIcon(c.label) }); + // A find alone does not narrow its argument: the guard is what tells the rest + // of the function that g is a panel key and not a group's. + const isDevPanel = (k: StripKey): k is DevPanel => DEV_PANELS.some((p) => p.key === k); + const stripChips = (g: Exclude): ChipDef[] => { // The two tool strips hold chips that arm the pointer rather than options to // pick, so groupDefs has nothing to say about them. if (g === 'fix') return fixChips.map(withIcon); if (g === 'gradient') return gradientChips.map(withIcon); + // LIGHT's four panels: the panel's rows first — each a knob that opens its + // ruler above — then the picks that close it, because a panel is read as its + // rows and the picks its name is about. + if (isDevPanel(g)) { + // The guard has just said the panel is in the table. + const panel = DEV_PANELS.find((p) => p.key === g) as (typeof DEV_PANELS)[number]; + return [ + { key: `hint-${g}`, label: panel.label, disabled: true, onClick: () => {} }, + ...panel.rows.map(devRow), + // WB closes on its presets and the two Color Chromes: a preset IS a cast + // (the swatch is painted on the button), and the chromes are the other + // axis of the two tracks above them. + ...(g === 'wb' + ? [...choiceChips('temp', groupDefs.temp.options, groupDefs.temp.value, groupDefs.temp.onPick), ...chromeChips] + : []), + // TONE closes on its curve: the graph on the photo is the shape of the + // range the seven rows above it are. + ...(g === 'tone' + ? [ + { + key: 'curve', + label: '∿ TONE CURVE', + active: openParam === 'curve', + amberValue: openParam !== 'curve' && curveIsActive(recipe.adjustments.toneCurve), + onClick: () => toggleParam('curve'), + }, + ] + : []), + // D.RANGE is a hold on the whole frame, and its four stops are one + // setting read as a set — so they stand in the strip the effects close + // rather than opening a strip of their own. + ...(g === 'effects' + ? choiceChips('dr', DR_OPTIONS, String(recipe.adjustments.dynamicRange ?? 'auto'), groupDefs.dr.onPick) + : []), + ].map(withIcon); + } const out: ChipDef[] = [ { key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} }, ...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick, g === 'style' ? simPreview : undefined).map((c) => ({ @@ -3233,6 +3363,49 @@ export function Workspace() { return out; }, [gpsInfo, gpsStyle.showName, gpsStyle.showTime]); + // What a strip calls itself, for the path under the bar. The panels answer + // with their own names, the two tool strips with theirs, and the rest are the + // option groups, whose label is the chip that opened them. + const stripLabel = (g: StripKey): string => { + const panel = DEV_PANELS.find((p) => p.key === g); + if (panel) return panel.label; + if (g === 'fix') return 'FIX'; + if (g === 'gradient') return 'GRADIENT MASK'; + if (g === 'wm') return 'WATERMARK'; + return groupDefs[g as Exclude].label; + }; + + // The path down the cascade, one name per level: the rail the bar stands in + // for, the tab, the strip it opened, the ruler that strip opened. A name + // returns to the strip it names — TABS back to the rail, LIGHT back to the + // tab's own chips, WB back to the panel — so there is no back button to hunt + // for. The last name is where the finger already is, and is a label. + const crumbs: { key: string; label: string; onClick?: () => void }[] = [ + { + key: 'tabs', + label: t('nav.tabs'), + onClick: () => { + setStripOpen(false); + setOpenGroup(null); + setOpenParam(null); + }, + }, + { + key: 'tab', + label: t(TABS.find((entry) => entry.id === tab)?.label ?? 'nav.studio'), + onClick: () => { + setOpenGroup(null); + setOpenParam(null); + }, + }, + ...(openGroup ? [{ key: 'strip', label: stripLabel(openGroup), onClick: () => setOpenParam(null) }] : []), + // A ruler is a row, not a strip: its name is the knob's, and the strip it + // came from is the level above — COLOR TEMP under WB. + ...(openParam + ? [{ key: 'ruler', label: slider?.label ?? DEV_DEF.get(openParam)?.label ?? openParam.toUpperCase() }] + : []), + ].map((c, i, all) => (i === all.length - 1 ? { ...c, onClick: undefined } : c)); + const status = busy; return ( @@ -3313,17 +3486,11 @@ export function Workspace() { }`} data-col="main" > - {/* The phone's way back. The bar holds this tab's own chips, so the - rail that picked them is one tap away — carrying the glyph of - the tab standing open, since the rail is what it stands in for - and the rail is off the screen. A desktop reads the rail beside - the chips and never draws this (see .tabs-back in app.css). */} -
- -
+ {/* The phone's way back is the path under the strip (see .crumb): + TABS > LIGHT > WB, each name returning to the strip it names. + The chip that used to stand here said the same thing with one + name; the path says how far down the visitor is, which is the + half of it the glyph could not. */} {tab === 'create' ? ( ) : ( <> - {/* 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. */} + {/* LIGHT's column is the sidebar itself — every pick of the tab + is a panel of DevelopPanels — AND its four panels as a chip + row. Which of the two a screen reads is the size of it: the + row is the phone's way into a panel (see DEV_PANELS), and + the column is the wide screen's, where every row is already + in sight and a row of chips would only repeat the heads. */} {tab === 'light' ? ( - + <> +
+ +
+ + ) : ( <> @@ -3627,6 +3802,27 @@ export function Workspace() { ) : null} + {/* The path back up, under the strip: TABS > LIGHT > WB. Drawn only while + a strip stands in the rail's place — there is no path to read while + the rail itself is on the screen — and never on a wide screen, where + every column is in sight already (see .crumb in styles/app.css). */} + {stripOpen ? ( + + ) : null} + {/* The stage's place in the workspace. The viewfinder covers the stage while it is open, and nothing else: the rail and the columns stay where they are, so a slider moves the look on the LIVE frames — the diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 96a67ab..094e493 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -114,10 +114,11 @@ /* RESET is the way out of an edit, not one more chip: a rule and some air keep 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); } -/* Two things that belong to the phone alone: the way back from a tab's strip to - the rail, and the mark a sub-chip wears on its tile. A desktop draws neither — - it reads the rail beside the chips, and a chip there is its label. */ -.tabs-back, .chip-icon { display: none; } +/* Three things that belong to the phone alone: the mark a sub-chip wears on its + tile, the path back up under the strip, and LIGHT's panels as a chip row. A + desktop draws none of them — a chip there is its label, every column is in + sight, and LIGHT's panels are the column itself. */ +.chip-icon, .crumb, .dev-chips { display: none; } .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 @@ -1563,30 +1564,76 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } overflow-y: hidden; background: var(--bg); border-top: 1px solid var(--border-soft); - padding: 6px 8px calc(6px + env(safe-area-inset-bottom)); + /* No inset here any more: the path under the strip (.crumb) is the last + line of the phone's stack now, so it carries the home indicator. */ + padding: 6px 8px; } /* The strip is as wide as its chips and the bar scrolls: a row that was allowed to shrink would squeeze under its own contents and the chips would paint over the ones beside them. */ .col-main > *, .col-sub:not(.wm-extra) > * { flex: 0 0 auto; } - .col-main:not(.col-create):not(.col-dev) .chip-row { flex-wrap: nowrap; } + .col-main:not(.col-create) .chip-row { flex-wrap: nowrap; } .col-main .chip { flex: 0 0 auto; } /* The rule a strip is read across runs the other way here: in a row of chips it divides the line, it does not break it. */ .col-main .chip-rule { flex: 0 0 auto; width: 1px; height: 18px; margin: 0 3px; } /* RESET is ruled off the tab's chips at the foot of a desktop column; in the - bar the rule moves to their side. */ - .reset-row { margin: 0; padding: 0 0 0 8px; border-top: none; border-left: 1px solid var(--border); } - /* LIGHT's panels and CREATE's form are not a strip of chips: they keep the - column's shape and scroll down instead of across, in the bar's place. */ - .col-main.col-create, .col-main.col-dev { + bar the rule moves to their side. The topbar's RESET already covers every + other tab (it and this row call the same reset), so the row only survives + where the button does something the topbar's does not: CREATE's form. */ + .reset-row { display: none; } + .col-create .reset-row { display: block; margin: 0; padding: 0 0 0 8px; border-top: none; border-left: 1px solid var(--border); } + /* CREATE's form is not a strip of chips: it keeps the column's shape and + scrolls down instead of across, in the bar's place. */ + .col-main.col-create { flex-direction: column; align-items: stretch; overflow-x: hidden; overflow-y: auto; } - /* The way back to the rail, which this strip is standing in for. */ - .tabs-back { display: flex; } + /* LIGHT is a strip of chips like any other tab, and its chips are its four + panels — so the widths of the panels' heads come off and the row stands in + the wrapper's place, exactly as .chips itself stands in its columns'. */ + .col-dev .dev-chips { display: contents; } + .col-dev .dev-panels { display: none; } + + /* --- the way back up: the path under the strip -------------------------- */ + /* TABS > LIGHT > WB. The bar is as deep as the visitor walked, so the bar's + own chips cannot say it; the path can, and each name in it returns to the + strip it names. It is the last line of the stack, so it, not the strip, + carries the home indicator. */ + .crumb { + order: 5; + display: flex; + align-items: center; + gap: 6px; + overflow-x: auto; + padding: 0 10px calc(6px + env(safe-area-inset-bottom)); + background: var(--bg); + border-top: 1px solid var(--border-soft); + font-family: var(--mono); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--text-dim); + } + .crumb::-webkit-scrollbar { display: none; } + .crumb-part { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; } + .crumb-sep { opacity: 0.5; } + /* A name in the path is a button wherever a strip answers to it, and plain + where the visitor already stands. */ + .crumb-btn, .crumb-here { padding: 6px 2px; } + .crumb-btn { + border: 0; + background: none; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + color: var(--text-dim); + cursor: pointer; + } + .crumb-btn:hover { color: var(--text); } + .crumb-here { color: var(--accent); } /* --- a chip's own sub-chips: the row over the bar ----------------------- */ .col-sub:not(.wm-extra) {