From c47ce212eb69e2bb6c2d8f6f7f89877ed200f82b Mon Sep 17 00:00:00 2001 From: 3dtours Date: Thu, 1 Oct 2026 06:49:36 +0700 Subject: [PATCH] =?UTF-8?q?web:=20LIGHT=20is=20four=20strips=20on=20a=20ph?= =?UTF-8?q?one,=20not=20four=20folded=20rows=20=E2=80=94=20the=20tab's=20c?= =?UTF-8?q?olumn=20measured=20215px=20of=20a=20390x844=20screen=20with=20i?= =?UTF-8?q?ts=20panels=20stacked,=20and=20the=20photo=20498px;=20the=20str?= =?UTF-8?q?ip=20is=2040px=20and=20the=20photo=20641px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LIGHT is the one tab whose picks are panels, and on a phone those panels were being drawn the way a wide screen draws them: four heads stacked down the bottom bar, each a disclosure for rows that were already there. The bar is a strip on every other tab, so it is a strip here: .dev-chips now carries the four panels as chips (WB, TONE, PRESENCE, DETAIL & EFFECTS) and .dev-panels is hidden under 860px, exactly as .dev-chips is hidden over it. The four chips fit a 390px strip without scrolling. WB, TONE, PRESENCE and DETAIL & EFFECTS become strip keys of their own (DEV_PANELS holds the rows each owns; DEV_DEF is one lookup for the defs, so a row the phone has and the web has not draws nothing). A panel's strip is its knobs first, then the picks that close it where they belong: WB's presets and its two Color Chromes, TONE's curve, DETAIL & EFFECTS' four D.RANGE stops. A knob in that strip opens its ruler in the row above, and the strip stays under it, so the panel is still on screen while the number moves — the first level of the cascade the other tabs already walk, one turn deeper. PARAM_GROUP is what that costs: every knob LIGHT owns names its panel (temperature -> wb, exposure -> tone, dehaze -> presence, grain -> effects), so the ruler's own "<" reopens the panel it came from instead of dropping the strip off the screen. A slider row opened from a strip returns to it. The way back up is a path under the strip: TABS > LIGHT > WB > COLOR TEMP, one name per level, each name returning to the strip it names and the last one drawn as where the visitor stands. It replaces the single ☀TABS chip: that chip said the same thing with one name where the path says how far down the visitor is, which is the half of it the glyph could not. It is the last line of the stack, so it, not the strip, carries the home-indicator inset now. A wide screen draws no path: every column is in sight there already. RESET leaves the bar. The topbar's RESET and this row both call the same reset(), so the row was a second button for one action, and on a phone it sat at the end of a strip that already scrolls sideways. CREATE keeps its own: that row also bumps createReset, which the topbar's button does not do. Measured on 390x844, LIGHT open: the bar 215px -> 40px, the photo 498px -> 641px, the path 32px; the four chips 390px of scroll width in a 390px strip, and the page itself does not scroll sideways. On 1280x900 .dev-panels is painted, .dev-chips and .crumb are not. Checks: - npm run build (tsc --noEmit + vite) clean: dist/assets/index-BAbl_ECT.css 71.65 kB, index-RU9cTwG7.js 735.50 kB. - node scripts/{tone-base,highlight-knee,half,white-level,auto-tone,preview-match, raw-develop,roll-walk,wb-table,mask-wb}-check.mjs: all pass. - Chromium 390x844 light and dark: rail -> LIGHT gives one 40px row of dev-wb/dev-tone/dev-presence/dev-effects and the path "TABS > LIGHT"; dev-wb gives the panel's strip over the bar (13 chips, 824px of scroll); COLOR TEMP opens its ruler above that strip, strip still up, path "TABS > LIGHT > WB > COLOR TEMP"; the ruler's "<" lands back on the WB strip; crumb-strip drops the ruler, crumb-tab drops the strip, crumb-tabs stands the rail back up; TONE closes on the curve, DETAIL & EFFECTS on the four D.RANGE stops; no page error, no sideways scroll. - Chromium 1280x900: .dev-panels painted, .dev-chips/.crumb none, WB panel opens with its presets, rows, chromes; no ruler column, as before. Skipped: - The row RESET on CREATE is kept, so that one bar still ends on a chip, where the topbar's button would leave the form's own reset unrun. - The path is not drawn over 860px, where the columns are already in sight. --- docker/frontend/src/App.tsx | 270 +++++++++++++++++++++++++---- docker/frontend/src/styles/app.css | 73 ++++++-- 2 files changed, 293 insertions(+), 50 deletions(-) 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) {