LIGHT drops its PROFILE panel, TONE CURVE folds into TONE, and IMPORT .RECIPE moves under the RECIPES chip

Three small moves in the column, plus the rule they needed.

LIGHT: the PROFILE panel is gone — PHOTO STYLE under PRESETS already covers
what it did — and the tone curve chip leaves the column's chip row. It becomes
the last line of the TONE panel's body, at the foot TONE always had room for.

PRESETS: IMPORT .RECIPE leaves the recipes strip and sits in the main column,
directly under the RECIPES chip, which is the set it imports into. The strip now
carries the recipes and nothing else, so its empty hint fires at length zero.

HSL: the mixer's PICK and the IMAGE label each get a rule above them. The rule
is a chip — divider: true in ChipDef — that renders a separator instead of a
button, so a row can say that what follows is a different kind of thing.

Verified by probe against the built app (exit 0, no console errors):
- LIGHT panel heads read WB, TONE, PRESENCE, DETAIL & EFFECTS, with no PROFILE;
  the TONE body's last row is the "∿ TONE CURVE" chip, box [94,647,300,27].
- PRESETS main chips read PHOTO STYLE, RECIPES, IMPORT .RECIPE (y 159, under
  RECIPES at y 124), RESET; the strip no longer carries the import chip.
- HSL order is PICK [94,91,148,27], rule [94,127,148,1], the eight bands, rule
  [94,404,148,1], IMAGE, then HUE/SAT/LUM. The same order holds at 393x852,
  where the rules measure [8,560,377,1] and [8,639,377,1].
- The rule is a 1px line: width 100% with height 1px, which keeps it a line in
  both the desktop column and the phone's wrapped strip.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-30 06:55:53 +07:00
parent 4476372b9e
commit f7031c1809
4 changed files with 124 additions and 83 deletions
+57 -39
View File
@@ -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: (
<ChipRow chips={choiceChips('sim', groupDefs.style.options, groupDefs.style.value, groupDefs.style.onPick, simPreview)} />
),
// 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: <ChipRow grid chips={chromeChips} />,
// 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: (
<ChipRow
chips={[
{
key: 'curve',
label: '∿ TONE CURVE',
active: openParam === 'curve',
amberValue: curveIsActive(recipe.adjustments.toneCurve),
onClick: () => 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' ? <ChipRow chips={chips} /> : 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' ? (
<DevelopPanels
adj={recipe.adjustments}
@@ -3270,7 +3282,13 @@ export function Workspace() {
slots={developSlots}
/>
) : (
<ChipRow chips={chips} />
<>
<ChipRow chips={chips} />
{/* 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 ? <ChipRow chips={[importChip]} /> : null}
</>
)}
</>
)}
@@ -3435,7 +3453,7 @@ export function Workspace() {
) : null}
{tab === 'presets' && showRecipes ? (
<div className="col col-sub strip" data-col="recipes">
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
{recipeStrip.length === 0 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}
<ChipRow chips={recipeStrip} />
</div>
) : null}
+12
View File
@@ -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; }
+44 -35
View File
@@ -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 (
<div className={`chip-row${grid ? ' grid' : ''}`}>
{chips.map((chip) => (
<button
key={chip.key}
type="button"
data-key={chip.key}
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}${chip.bg ? ' tinted' : ''}`}
style={chip.bg ? { background: chip.bg, color: chip.fg } : undefined}
aria-pressed={!!chip.active}
disabled={chip.disabled}
draggable={!!chip.drag}
onDragStart={
chip.drag
? (e) => {
e.dataTransfer.setData('text/plain', chip.drag!);
e.dataTransfer.effectAllowed = 'copy';
}
: undefined
}
onMouseEnter={chip.onHover}
onMouseLeave={chip.onHoverEnd}
// The click takes the preview off first: a click that applies the look
// (and closes the column the chip was in) would otherwise leave a
// preview nothing is left to take off — and every later knob would
// move the recipe under a photo still painted from the preview.
onClick={() => {
chip.onHoverEnd?.();
chip.onClick();
}}
>
{chip.color ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
{chip.label}
{chip.value ? <span className="val">{chip.value}</span> : null}
{chip.pro ? <span className="pro">PRO</span> : null}
</button>
))}
{chips.map((chip) =>
chip.divider ? (
<span key={chip.key} className="chip-rule" role="separator" aria-orientation="horizontal" />
) : (
<button
key={chip.key}
type="button"
data-key={chip.key}
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}${chip.bg ? ' tinted' : ''}`}
style={chip.bg ? { background: chip.bg, color: chip.fg } : undefined}
aria-pressed={!!chip.active}
disabled={chip.disabled}
draggable={!!chip.drag}
onDragStart={
chip.drag
? (e) => {
e.dataTransfer.setData('text/plain', chip.drag!);
e.dataTransfer.effectAllowed = 'copy';
}
: undefined
}
onMouseEnter={chip.onHover}
onMouseLeave={chip.onHoverEnd}
// The click takes the preview off first: a click that applies the look
// (and closes the column the chip was in) would otherwise leave a
// preview nothing is left to take off — and every later knob would
// move the recipe under a photo still painted from the preview.
onClick={() => {
chip.onHoverEnd?.();
chip.onClick();
}}
>
{chip.color ? <span className="chip-dot" data-color={chip.color} style={{ background: chip.color }} /> : null}
{chip.label}
{chip.value ? <span className="val">{chip.value}</span> : null}
{chip.pro ? <span className="pro">PRO</span> : null}
</button>
)
)}
</div>
);
}
+11 -9
View File
@@ -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<ColorAdjustments>) => 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<Record<string, boolean>>(() => {
const saved = new Set((recall(OPEN_KEY) ?? '').split(','));