web: put the brushes in a FIX strip and the shapes in a GRADIENT MASK one

The FX row had grown into a flat list where a brush and a filter and a
shape sat side by side, and it lied about what the tools are: HEAL and
MOSAIC only paint, LINEAR and RADIAL only make a mask.  The row now
carries FIX and GRADIENT MASK, in that order, before MONOCHROME, and
each one opens its own strip holding the tools that belong to it —
turning amber when it has a spot or a mask to show for itself, so the
state still reads from the row without opening anything.

The two brushes moved into the FIX strip with the header FIX above
them, the two shapes into the GRADIENT MASK strip under the header
GRADIENT MASK, and each CLEAR moved in with the tool it clears instead
of sitting at the end of the row.  Opening FIX still arms the brush the
same way — the strip only changes where the chip lives, not what
clicking it does.

The mask column (shine, bearing, feather, and the rest) still hangs off
the shape you pick, so it now stands right after the options column:
the strip that brought it out comes first, then the column it belongs
to.  Nothing else in the column order moved.

ponytail: the two strips ride the existing openGroup and toggleGroup, so
a strip key is just a widened GroupKey rather than new state to keep in
sync; the option-strip body itself stayed where it was, keeping the
diff to the chips that moved.

Verified:
- npx tsc --noEmit clean.
- Frontend probes against the built production bundle on :8090 and the
  dev server: mask-probe 53/0, brush-edit-probe 35/0, heal-idle-probe
  23/0, heal-zoom-drag-probe 28/0, grain-controls 20/0, temp-swatch
  33/0, and landing, pro-gate, award-column, otp-code, tone-curve,
  hsl-panel, chip-edge, chips-desk, slider-reset all PASS rc=0 fails=0.
- Backend npm test 180/0.
- panel-test keeps exactly its four pre-existing failures (rail labels,
  WB swatch); they reproduce on the commit before this one.
This commit is contained in:
2026-09-24 12:20:33 +07:00
parent cca6fc46f7
commit 08a4570b2d
+112 -72
View File
@@ -165,11 +165,16 @@ const SLIDER_SWATCH: Partial<Record<string, (v: number) => string>> = {
};
// The option strips, one open at a time (phone's GroupKey set minus the groups
// the web engine has no knob for).
// the web engine has no knob for). Two strips are of another kind: FIX holds the
// two brushes, GRADIENT MASK the two shapes, and their chips arm the POINTER
// rather than pick a value — so they open a strip like the rest and are not
// entries in groupDefs, which is where the strips that pick keep their options.
type GroupKey = 'cx' | 'cxb' | 'dr' | 'crop' | 'rotate' | 'style' | 'temp' | 'grain' | 'wm'
| 'wmColor' | 'wmSize' | 'wmFont' | 'gpsColor' | 'gpsName' | 'gpsTime' | 'gpsFont';
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime', 'gpsFont'];
const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
type ToolStrip = 'fix' | 'gradient';
type StripKey = GroupKey | ToolStrip;
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',
@@ -316,7 +321,7 @@ export function Workspace() {
const [sample, setSample] = useState<{ r: number; g: number; b: number } | null>(null);
// Where on the photo that colour was read: the mixer's panel hangs there.
const [pickedAt, setPickedAt] = useState<{ fx: number; fy: number } | null>(null);
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
const [openGroup, setOpenGroup] = useState<StripKey | null>(null);
// The TEMP preset the user last tapped. AUTO and DAYLIGHT are the same pair on
// the engine, so the pair alone cannot say which chip is lit — the phone keeps
// the same memory (wbChoice in AdjustmentPanel).
@@ -1792,7 +1797,7 @@ export function Workspace() {
);
const toggleGroup = useCallback(
(g: GroupKey) => {
(g: StripKey) => {
const next = openGroup === g ? null : g;
setMaskTool(null);
if (next) {
@@ -2491,62 +2496,35 @@ export function Workspace() {
groupChip('cxb'),
];
case 'fx': {
// FX's two brushes are the tab's tools that are not a look: a chip arms
// one, the wheel sizes it — the chip's own readout is that size, since
// the brush has no size buttons to point at — and CLEAR hands the whole
// photo back. HEAL repairs a speck of dust, MOSAIC hides a patch of the
// photo from whoever looks at it; they sit above the look chips because
// they change the file rather than grade it.
// FX's tools are not looks: each is a strip of its own, opened from the
// row and holding the two chips that arm the pointer — FIX's brushes and
// GRADIENT MASK's shapes. The strip's own chip carries the name and glows
// amber while there is something inside it to clear, so the row above the
// look chips stays a row of looks.
return [
{
key: 'heal',
label: 'HEAL',
value: brushTool === 'heal' ? `${(healR * 100).toFixed(1)}%` : undefined,
active: brushTool === 'heal',
amberValue: healSpots.length > 0,
key: 'fix',
label: 'FIX',
active: openGroup === 'fix',
amberValue: healSpots.length > 0 || mosaicSpots.length > 0,
onClick: () => {
// The tools that take the pointer on the photo never share it.
setPicking(false);
setMaskTool(null);
setBrushTool((v) => (v === 'heal' ? null : 'heal'));
setBrushTool(null);
toggleGroup('fix');
},
},
...(healSpots.length
? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }]
: []),
{
key: 'mosaic',
label: 'MOSAIC',
value: brushTool === 'mosaic' ? `${(mosaicR * 100).toFixed(1)}%` : undefined,
active: brushTool === 'mosaic',
amberValue: mosaicSpots.length > 0,
onClick: () => {
setPicking(false);
setMaskTool(null);
setBrushTool((v) => (v === 'mosaic' ? null : 'mosaic'));
},
},
...(mosaicSpots.length
? [{ key: 'mosaic-clear', label: 'CLEAR', onClick: clearMosaicSpots }]
: []),
// LINEAR and RADIAL GRADIENT are the tab's other two tools that take
// the pointer: a chip arms one and the next drag on the photo lays the
// shape down, inside which the column beside it grades the pixels. The
// chip carries the shape's own name rather than its count, so CLEAR
// below it can, and the pair sits with HEAL and MOSAIC because all
// four change the photo where it is rather than the look on top of it.
...(['linear', 'radial'] as const).map((kind): ChipDef => ({
key: kind,
label: kind.toUpperCase(),
active: maskTool === kind,
amberValue: masks.some((m) => m.kind === kind),
key: 'gradient-mask',
label: 'GRADIENT MASK',
active: openGroup === 'gradient',
amberValue: masks.length > 0,
onClick: () => {
setPicking(false);
setBrushTool(null);
setMaskTool((v) => (v === kind ? null : kind));
toggleGroup('gradient');
},
})),
...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []),
},
{ key: 'mono', label: 'MONOCHROME', active: monoOn, onClick: toggleMono },
// GRAIN is a strip of its own — amount, size, and the count they add up
// to — so it is one chip here and its two knobs live inside it; the
@@ -2613,7 +2591,67 @@ export function Workspace() {
// eslint-disable-next-line react-hooks/exhaustive-deps
})();
const stripChips = (g: Exclude<GroupKey, 'wm'>): ChipDef[] => [
// FIX's two brushes: a chip arms one, the wheel sizes it — the chip's own
// readout is that size, since the brush has no size buttons to point at — and
// CLEAR hands the whole photo back. HEAL repairs a speck of dust, MOSAIC hides
// a patch of the photo from whoever looks at it.
const fixChips: ChipDef[] = [
{ key: 'hint-fix', label: 'FIX', disabled: true, onClick: () => {} },
{
key: 'heal',
label: 'HEAL',
value: brushTool === 'heal' ? `${(healR * 100).toFixed(1)}%` : undefined,
active: brushTool === 'heal',
amberValue: healSpots.length > 0,
onClick: () => {
// The tools that take the pointer on the photo never share it.
setPicking(false);
setMaskTool(null);
setBrushTool((v) => (v === 'heal' ? null : 'heal'));
},
},
...(healSpots.length ? [{ key: 'heal-clear', label: 'CLEAR', onClick: clearHealSpots }] : []),
{
key: 'mosaic',
label: 'MOSAIC',
value: brushTool === 'mosaic' ? `${(mosaicR * 100).toFixed(1)}%` : undefined,
active: brushTool === 'mosaic',
amberValue: mosaicSpots.length > 0,
onClick: () => {
setPicking(false);
setMaskTool(null);
setBrushTool((v) => (v === 'mosaic' ? null : 'mosaic'));
},
},
...(mosaicSpots.length ? [{ key: 'mosaic-clear', label: 'CLEAR', onClick: clearMosaicSpots }] : []),
];
// GRADIENT MASK's two shapes are the tab's other tools that take the pointer: a
// chip arms one and the next drag on the photo lays the shape down, inside
// which the column beside it grades the pixels. The chip carries the shape's
// own name rather than its count, so CLEAR below it can.
const gradientChips: ChipDef[] = [
{ key: 'hint-gradient', label: 'GRADIENT MASK', disabled: true, onClick: () => {} },
...(['linear', 'radial'] as const).map((kind): ChipDef => ({
key: kind,
label: kind.toUpperCase(),
active: maskTool === kind,
amberValue: masks.some((m) => m.kind === kind),
onClick: () => {
setPicking(false);
setBrushTool(null);
setMaskTool((v) => (v === kind ? null : kind));
},
})),
...(masks.length ? [{ key: 'masks-clear', label: 'CLEAR', onClick: clearMasks }] : []),
];
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;
if (g === 'gradient') return gradientChips;
return [
{ key: `hint-${g}`, label: groupDefs[g].label, disabled: true, onClick: () => {} },
...choiceChips(g, groupDefs[g].options, groupDefs[g].value, groupDefs[g].onPick).map((c) =>
// A hand-straightened frame is no longer on a clean quarter turn: the
@@ -2645,7 +2683,8 @@ 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
@@ -2851,27 +2890,7 @@ export function Workspace() {
</div>
</div>
{/* column 2 — FX's GRADIENT MASK, while a mask is chosen: the shape's own
name, the way to take it off the photo, and its knobs as the app's
own rulers — one parameter to a row. A radial mask adds the feather
it fades over, since that is the number its rim is made of. The
column is the mask tool's, so putting that tool down — which is what
another chip does — takes the column with it. */}
{maskTool && selMask ? (
<div className="col col-sub strip" data-col="mask">
<ChipRow
chips={[
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
]}
/>
{maskRulers.map((def) => (
<SliderRow key={def.key} slider={def} />
))}
</div>
) : null}
{/* column 2b — FRAME's WATERMARK chip opens the two collapses, one per
{/* FRAME's WATERMARK chip opens the two collapses, one per
watermark type: the header chip, then that mark's own controls */}
{wmOpen ? (
<div className="col col-sub strip wm-extra" data-col="wm">
@@ -2971,6 +2990,27 @@ export function Workspace() {
) : null}
</div>
) : null}
{/* FX's GRADIENT MASK strip, once one of its shapes is chosen: the
shape's own name, the way to take it off the photo, and its knobs as
the app's own rulers — one parameter to a row. A radial mask adds the
feather it fades over, since that is the number its rim is made of.
The column is the mask tool's, so putting that tool down — which is
what another chip does — takes the column with it. It stands after
the strip it came out of, the way a ruler stands after its own. */}
{maskTool && selMask ? (
<div className="col col-sub strip" data-col="mask">
<ChipRow
chips={[
{ key: 'mask-kind', label: selMask.kind.toUpperCase(), active: true, onClick: () => {} },
{ key: 'mask-delete', label: 'DELETE', onClick: deleteMask },
]}
/>
{maskRulers.map((def) => (
<SliderRow key={def.key} slider={def} />
))}
</div>
) : null}
{tab === 'presets' && showRecipes ? (
<div className="col col-sub strip" data-col="recipes">
{recipeStrip.length === 1 ? <p className="hint">{t('sec.savedEmpty')}</p> : null}