import { useEffect, useRef } from 'react'; // 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 // chip row. Chip labels are the phone's own strings (they are not translated // there either). export interface ChipDef { key: string; label: string; value?: string; // amber border while this chip's row/strip is open, amber text when the value // it carries is not its default. active?: boolean; amberValue?: boolean; pro?: boolean; disabled?: boolean; // CSS colour drawn as a dot on the chip's leading edge — the HSL mixer's band // chips name a colour, so they show it too. color?: string; // The button's face on a phone, where a sub-chip is a tile: a short mark over // the name (see .col-sub .chip in app.css). Desktop never paints it — the // label alone is what a chip reads as in a column. icon?: string; // The chip's own FILL and the text colour that fill can carry (WB's preset // table: the swatch the pick paints, printed on the button itself). `bg` wins // over the class's own background, so an active button keeps its swatch and // 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; // HOVER PREVIEW: a look the pointer lays on the photo before any click. Only // the chips that carry a whole look (a sim, a recipe) have one; the pair is // always both or neither, so leaving the chip always takes it back off. onHover?: () => void; onHoverEnd?: () => void; onClick: () => void; } export interface SliderDef { key: string; label: string; value: number; min: number; max: number; step: number; display: string; // amber value readout once the knob leaves its default (phone's ruler row) edited: boolean; // CSS colour the knob is currently driving (WB's temperature/tint), drawn as a // swatch under the ruler so the hue moves with the number. Absent = no swatch. swatch?: string; onChange: (value: number) => void; // Double-clicking the track puts the parameter back to its default. onReset: () => void; // "<" — closes the ruler and reopens the strip it was launched from. Left out // by a panel that IS the strip: a mask's own column has nowhere to go back to. onBack?: () => void; } // `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 }: { chips: ChipDef[]; grid?: boolean }) { return (
{chips.map((chip) => chip.divider ? ( ) : ( ) )}
); } 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 — // the panel must not scroll away under the cursor while the value moves. const rowRef = useRef(null); const live = useRef(slider); live.current = slider; useEffect(() => { const el = rowRef.current; if (!el) return; const onWheel = (e: WheelEvent) => { e.preventDefault(); const s = live.current; const step = s.step || 1; const next = Math.min(s.max, Math.max(s.min, s.value + (e.deltaY < 0 ? step : -step))); if (next !== s.value) s.onChange(next); }; el.addEventListener('wheel', onWheel, { passive: false }); return () => el.removeEventListener('wheel', onWheel); }, []); return ( // No data-key on the row itself: the parameter's own key is already on the // chip that opened it, and a second element answering to it would make a // probe's own selector ambiguous.
{slider.onBack ? ( ) : null}
{/* Label, track, readout: one line, the way a ruler is read — the name of the knob at the head of the row, the number it is on at the foot, and the travel between them taking the width that is left. */}
{slider.label} slider.onChange(Number(e.target.value))} onDoubleClick={slider.onReset} /> {slider.display}
{slider.swatch ? (
{slider.swatch}
) : null}
); } // The same ruler without its own column: one knob of the mixer's inline panel, // where HUE/SAT/LUM sit side by side under the colour they are moving — and, in // the same shape, the knobs a gradient mask carries. Wheel steps it, double-click // puts it back to 0. A mask's knobs are not the mixer's -10..+10 integers (an // exposure is a stop, a feather is a percentage), so the range and the readout // come in with them; the defaults are the mixer's own, unchanged. export function MiniSlider({ label, value, min = -10, max = 10, step = 1, prefix = 'hsl-knob', format, defaultValue = 0, track, dataKey, onChange, onReset, }: { label: string; value: number; min?: number; max?: number; step?: number; prefix?: string; format?: (value: number) => string; // The value the readout stays grey at. 0 is the mixer's own default, but the // studio's WB rows are not the only knobs here: a temperature's default is // 5500K and a grain size's is 100, so the caller that knows says so. defaultValue?: number; // A coloured track for the two WB knobs (CSS track-temp / track-tint), whose // travel is a hue rather than a number. track?: 'temp' | 'tint'; // Replaces the `-