diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx
index 51fc7c0..8d199c3 100644
--- a/docker/frontend/src/App.tsx
+++ b/docker/frontend/src/App.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TABS, ToolRail, type TabId } from './ui/ToolRail';
-import { ChipRow, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
+import { ChipRow, ColGrip, MiniSlider, SliderRow, type ChipDef, type SliderDef } from './ui/ChipColumn';
import { ImageStage } from './ui/ImageStage';
import { StraightenRuler } from './ui/StraightenRuler';
import { ToneCurvePanel } from './ui/ToneCurvePanel';
@@ -3291,6 +3291,11 @@ export function Workspace() {
// eslint-disable-next-line react-hooks/exhaustive-deps
})();
+ // The two tabs whose own chips are names to read down rather than buttons to
+ // press out of a row: their column drops the chip's chrome (ChipRow's `list`)
+ // and takes a grip.
+ const listTab = tab === 'photos' || tab === 'favorited';
+
// 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
@@ -3829,10 +3834,13 @@ export function Workspace() {
/>
>
) : (
-
+
)}
>
)}
+ {/* MY PHOTOS and FAVORITED are lists of names rather than buttons, so
+ their column is the visitor's to widen (see ColGrip). */}
+ {listTab ? : null}
{tab === 'favorited' && saved.length === 0 ?
{t('sec.savedEmpty')}
: null}
{/* The mixer's readout: the colour the eyedropper last read, as the
RGB bytes and the HSL the band is indexed by. */}
@@ -3952,7 +3960,10 @@ export function Workspace() {
{/* column 3 — the open chip's sub-chips, or PRESETS' RECIPES list */}
{optGroup ? (
-
+
+ {/* PHOTO STYLE is one name per look, so its strip is the visitor's
+ to widen — the sims' names are longer than the column. */}
+ {optGroup === 'style' ?
: null}
{/* A watermark's colour: the presets above are a shortcut, the
well under them picks any colour at all. */}
{optGroup === 'wmColor' || optGroup === 'gpsColor' ? (
@@ -3995,7 +4006,10 @@ export function Workspace() {
{tab === 'presets' && showRecipes ? (
{recipeStrip.length === 0 ?
{t('sec.savedEmpty')}
: null}
-
+
+ {/* RECIPES reads the same way MY PHOTOS does: names, and a width
+ of the visitor's own. */}
+
) : null}
diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css
index bdf7420..31dff57 100644
--- a/docker/frontend/src/styles/app.css
+++ b/docker/frontend/src/styles/app.css
@@ -105,7 +105,33 @@
padding: 10px;
overflow-y: auto;
border-right: 1px solid var(--border-soft);
+ /* The grip on this column's edge is placed against it (see .col-grip). */
+ position: relative;
}
+/* A list read as names instead of as buttons: MY PHOTOS' saved frames, FAVORITED
+ and RECIPES' looks, PHOTO STYLE's sims. No border, no fill, no pill — the text
+ alone, with the accent still naming the one in force. */
+.chip-row.list .chip {
+ border: none;
+ background: none;
+ border-radius: 0;
+ padding: 2px 0;
+}
+.chip-row.list .chip:hover .chip-label { text-decoration: underline; }
+/* The edge of a column, dragged to widen it (see ColGrip): a strip of the cursor
+ over the border that is already there. `touch-action: none` is what lets a
+ finger drag it rather than scroll the column. */
+.col-grip {
+ position: absolute;
+ top: 0;
+ right: -4px;
+ bottom: 0;
+ width: 9px;
+ cursor: col-resize;
+ touch-action: none;
+ z-index: 3;
+}
+.col-grip:hover, .col-grip:active { background: var(--accent-soft); }
.col:last-child { border-right: none; }
.col-slider { width: var(--slider-w); }
/* CREATE RECIPES draws a two-up form, so its column is the wide one. */
diff --git a/docker/frontend/src/ui/ChipColumn.tsx b/docker/frontend/src/ui/ChipColumn.tsx
index ddebe51..a4525f9 100644
--- a/docker/frontend/src/ui/ChipColumn.tsx
+++ b/docker/frontend/src/ui/ChipColumn.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react';
+import { keep, recall } from '../remember';
// One strip at a time — the same shape as the phone's AdjustmentPanel: a ruler
// row for the open slider, an options strip for the open group, then the tab's
@@ -62,10 +63,13 @@ export interface SliderDef {
// `grid` lays the row out as the table a set of same-kind values is read as
// (WB's presets, LIGHT's two Color Chromes) instead of the wrapping strip the
-// option rows use.
-export function ChipRow({ chips, grid, grid3 }: { chips: ChipDef[]; grid?: boolean; grid3?: boolean }) {
+// option rows use. `list` is the other end of it: the rows that are a LIST OF
+// NAMES — MY PHOTOS' frames, FAVORITED and RECIPES' looks, PHOTO STYLE's sims —
+// drop the button's chrome and read as plain text, with only the accent marking
+// the one in force (see .chip-row.list).
+export function ChipRow({ chips, grid, grid3, list }: { chips: ChipDef[]; grid?: boolean; grid3?: boolean; list?: boolean }) {
return (
-
+
{chips.map((chip) =>
chip.divider ? (
@@ -114,6 +118,48 @@ export function ChipRow({ chips, grid, grid3 }: { chips: ChipDef[]; grid?: boole
);
}
+// The edge of a column you can drag: the width is written on the column itself
+// and remembered (remember.ts), so the next visit opens at the size it was left
+// at. Drag, not a panel: the grip appears where the column's edge already is.
+// ponytail: the DOM takes the width and React never learns it — a column width
+// is presentation, not state, and a re-render carrying it would fight the drag.
+export function ColGrip({ col, min = 110, max = 560 }: { col: string; min?: number; max?: number }) {
+ const ref = useRef
(null);
+ const key = `studio-col-${col}`;
+ useEffect(() => {
+ const box = ref.current?.parentElement;
+ const kept = recall(key);
+ if (box && kept) box.style.width = kept;
+ }, [key]);
+ return (
+ {
+ const box = ref.current?.parentElement;
+ if (!box) return;
+ e.preventDefault();
+ const from = e.clientX;
+ const start = box.getBoundingClientRect().width;
+ const move = (m: PointerEvent) => {
+ box.style.width = `${Math.round(Math.min(max, Math.max(min, start + m.clientX - from)))}px`;
+ };
+ const up = () => {
+ window.removeEventListener('pointermove', move);
+ window.removeEventListener('pointerup', up);
+ keep(key, box.style.width);
+ };
+ window.addEventListener('pointermove', move);
+ window.addEventListener('pointerup', up);
+ }}
+ />
+ );
+}
+
export function SliderRow({ slider }: { slider: SliderDef }) {
// The wheel belongs to the open ruler: one notch is one step, held inside the
// parameter's own range. The listener is native because React's is passive —