web: LIGHT is four strips on a phone, not four folded rows — the tab's column measured 215px of a 390x844 screen with its panels stacked, and the photo 498px; the strip is 40px and the photo 641px

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.
This commit is contained in:
2026-10-01 06:49:36 +07:00
parent 6bbf18dc44
commit c47ce212eb
2 changed files with 293 additions and 50 deletions
+233 -37
View File
@@ -197,15 +197,41 @@ const SLIDER_SWATCH: Partial<Record<string, (v: number) => 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<Record<string, GroupKey>> = {
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<Record<string, StripKey>> = {
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<StripKey, 'wm'>): 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<GroupKey, 'wm'>].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). */}
<div className="chip-row tabs-back">
<button type="button" className="chip" data-key="tabs-back" onClick={() => setStripOpen(false)}>
<span className="chip-icon" aria-hidden="true">{TABS.find((e) => e.id === tab)?.glyph ?? '‹'}</span>
<span className="chip-label">{t('nav.tabs')}</span>
</button>
</div>
{/* 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' ? (
<RecipeCreatePanel
currentBase={recipe.baseFilter}
@@ -3381,17 +3548,25 @@ export function Workspace() {
</>
) : (
<>
{/* 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' ? (
<DevelopPanels
adj={recipe.adjustments}
set={setAdjustmentOnce}
pro={pro}
onPro={promptPro}
slots={developSlots}
/>
<>
<div className="dev-chips">
<ChipRow chips={chips} />
</div>
<DevelopPanels
adj={recipe.adjustments}
set={setAdjustmentOnce}
pro={pro}
onPro={promptPro}
slots={developSlots}
/>
</>
) : (
<>
<ChipRow chips={chips} />
@@ -3627,6 +3802,27 @@ export function Workspace() {
) : null}
</div>
{/* 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 ? (
<nav className="crumb" data-key="crumb" aria-label={t('nav.tabs')}>
{crumbs.map((c, i) => (
<span className="crumb-part" key={c.key}>
{i ? <span className="crumb-sep" aria-hidden="true">&gt;</span> : null}
{c.onClick ? (
<button type="button" className="crumb-btn" data-key={`crumb-${c.key}`} onClick={c.onClick}>
{c.label}
</button>
) : (
<span className="crumb-here" aria-current="page">{c.label}</span>
)}
</span>
))}
</nav>
) : 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
+60 -13
View File
@@ -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) {