6bbf18dc44
LIBRARY, RESET, SAVE PHOTO and EXPORT are icons on a phone from here on. The recipe is the one the file already used for the stage's toolbar: font-size 0 takes the word out of the paint, never out of the button, so every accessible name is exactly the word that is no longer drawn, and a ::before carries the glyph (▤ ⟲ ⤓ ↗ — all of them already spoken in this app). The row's own content measured 990px wide with the words and 716px with the icons, and the bar it sat in measured 135px tall on a 390px screen before this and 88px after: three rows to two. RESET also gains a data-key (`reset-look`) — the CSS has to be able to name it. EXPORT moves to the end of the <header>, after the three menus. It is the end of the job and now the end of the row, which is the button the eye should land on once the edit is done. SAVE PHOTO's PRO marker goes with the word: a 9px badge inside a 34px button is a word in a box too small for it, and the count that shares the label has no room either. The button keeps its title, and the count is the one thing the phone loses here — the wide screen still counts. The header is now `position: sticky; top: 0; z-index: 5` on a phone. The page never scrolls (the stage does), so nothing moved before this and nothing moves now — but that is a promise the layout can keep rather than a coincidence of who scrolls. `.adm-bar` already carried the same three lines. The tab strip at the foot grows to the size a thumb is: pills 10px on 8x12 and 28px tall become 12px on 0x16 with a 44px floor, and the bar's own padding goes 6x8 to 8x10. Measured: a pill 28px to 44px, the bar 40px to 61px. This commit also lands the strip work from the session before it, which was written and probed but never committed: under 860px the rail of tabs and the tab's chips become one bar at a time (`.workspace.strip-open`), a TABS chip stands the rail back up, the open chip's sub-chips stand over the bar as 60px tiles, and the ruler is drawn as ticks on a bare input. It shares app.css with the bar above, so the phone's block lands in one piece. Checks: - npm run build (tsc --noEmit + vite) clean. - The ten browser-free image checks (tone-base, highlight-knee, half, white-level, auto-tone, preview-match, raw-develop, roll-walk, wb-table, mask-wb) all pass. - Playwright at 390x844, light and dark (scratchpad topbar-probe.mjs): header sticky, top 0px, z-index 5, y=0 h=88, lastElementChild data-key export-photo; the four buttons 34x30 with font-size 0 and a 15px ::before; the rail at y=783 h=61 with a 93x44 pill at 12px; the theme popover inside the screen (x=47, right=382); every scroller set to 400 leaves headerY at 0; strip-open keeps headerY 0 with the rail display:none; no page errors. - Playwright at 1280 (scratchpad topbar-desktop.mjs): header static, one row 50px, the same four buttons at 14px with no ::before, EXPORT last. Skipped: no new aria-label — the words the icons replace are still the buttons' own text, so nothing needs one. Add one only if a label leaves the DOM.
269 lines
11 KiB
TypeScript
269 lines
11 KiB
TypeScript
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 (
|
|
<div className={`chip-row${grid ? ' grid' : ''}`}>
|
|
{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.icon ? <span className="chip-icon" aria-hidden="true">{chip.icon}</span> : null}
|
|
{/* The name is a box, not the bare text node it used to be: a flex
|
|
container turns a loose text node into an anonymous item of its
|
|
own, and on iOS that item stops being painted until the column
|
|
it sits in is touched. Same fault, same shape as the panel head
|
|
(see `dev-panel-label` in DevelopPanels). No rule of its own. */}
|
|
<span className="chip-label">{chip.label}</span>
|
|
{chip.value ? <span className="val">{chip.value}</span> : null}
|
|
{chip.pro ? <span className="pro">PRO</span> : null}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<HTMLDivElement>(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.
|
|
<div className="slider-row" ref={rowRef}>
|
|
{slider.onBack ? (
|
|
<button type="button" className="back" onClick={slider.onBack} aria-label="back"><</button>
|
|
) : null}
|
|
<div className="ruler">
|
|
{/* 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. */}
|
|
<div className="head">
|
|
<span>{slider.label}</span>
|
|
<input
|
|
type="range"
|
|
min={slider.min}
|
|
max={slider.max}
|
|
step={slider.step}
|
|
value={slider.value}
|
|
aria-label={slider.label}
|
|
onChange={(e) => slider.onChange(Number(e.target.value))}
|
|
onDoubleClick={slider.onReset}
|
|
/>
|
|
<b className={slider.edited ? 'amber' : ''}>{slider.display}</b>
|
|
</div>
|
|
{slider.swatch ? (
|
|
<div
|
|
className="swatch"
|
|
data-key={`swatch-${slider.key}`}
|
|
data-color={slider.swatch}
|
|
style={{ background: slider.swatch }}
|
|
title={slider.swatch}
|
|
>
|
|
<span>{slider.swatch}</span>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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 `<prefix>-<label>` key for a caller that addresses a row by the
|
|
// parameter's own key (the sidebar's dev-<key>) instead of its label.
|
|
dataKey?: string;
|
|
onChange: (value: number) => void;
|
|
onReset: () => void;
|
|
}) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const live = useRef({ value, onChange });
|
|
live.current = { value, onChange };
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const onWheel = (e: WheelEvent) => {
|
|
// The panel lives on the stage, whose own wheel zooms the photo: the
|
|
// knob takes the notch and keeps it.
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const s = live.current;
|
|
const next = Math.max(min, Math.min(max, 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);
|
|
}, [min, max, step]);
|
|
return (
|
|
<div className="mini-slider" ref={ref} data-key={dataKey ?? `${prefix}-${label}`}>
|
|
{/* The studio's rows are one line each, the way a ruler is read: the
|
|
knob's name, the track, then the number it is on. The mixer's three
|
|
knobs are the exception — they sit three to a narrow panel, where a
|
|
track between two labels would be a sliver, so they stack (see
|
|
.hsl-card-knobs in app.css). */}
|
|
<div className="mini-head">
|
|
<span>{label}</span>
|
|
<input
|
|
type="range"
|
|
min={min}
|
|
max={max}
|
|
step={step}
|
|
value={value}
|
|
aria-label={label}
|
|
className={track ? `track-${track}` : undefined}
|
|
onChange={(e) => onChange(Number(e.target.value))}
|
|
onDoubleClick={onReset}
|
|
/>
|
|
<b className={value !== defaultValue ? 'amber' : ''} data-key={`${prefix}-${label}-value`}>
|
|
{format ? format(value) : value > 0 ? `+${value}` : String(value)}
|
|
</b>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|