diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 932b6be..49e682c 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -99,6 +99,10 @@ const BASE_RECIPE: Recipe = { // gate owns lives where it is used: the GPS stamp in wmChips, the HDF effect in // paramDefs (its own `pro` flag), and the export tier in TIERS. const PRO_FRAMES: FrameId[] = ['classic-white', 'polaroid', 'wallframe', 'wallframe-landscape']; +// The three newest looks on the PHOTO STYLE strip are the account's own: the +// pushed monochrome and the Leica rangefinder pair, which are the sims the +// studio sells as the reason to prove the address. +const PRO_SIMS: string[] = ['sim-bw-high-contrast', 'sim-leica', 'sim-leica-vivid']; // FREE draws the frame on the canvas and lets the user drag its corners, so // every ratio is offered here — no ratio is applied until APPLY. @@ -1167,9 +1171,25 @@ export function Workspace() { const proLookInUse = useCallback( () => PRO_FRAMES.includes(frameId) || + PRO_SIMS.includes(simId ?? '') || useGeotag || - (recipe.adjustments.hdf ?? 0) > 0, - [frameId, useGeotag, recipe.adjustments.hdf] + (recipe.adjustments.hdf ?? 0) > 0 || + // The mixer, on either of its two axes: a band's HUE/SAT/LUM, or the + // image-wide three that sit under the IMAGE divider. + Object.keys(recipe.adjustments.hslBands ?? {}).length > 0 || + (recipe.adjustments.hslHue ?? 0) !== 0 || + (recipe.adjustments.hslSat ?? 0) !== 0 || + (recipe.adjustments.hslLum ?? 0) !== 0, + [ + frameId, + simId, + useGeotag, + recipe.adjustments.hdf, + recipe.adjustments.hslBands, + recipe.adjustments.hslHue, + recipe.adjustments.hslSat, + recipe.adjustments.hslLum, + ] ); // `true` when a file was handed over, so CLEAR knows the export it offered @@ -1705,7 +1725,7 @@ export function Workspace() { label: string; off: string; value: string; - options: { v: string; d: string }[]; + options: { v: string; d: string; pro?: boolean }[]; onPick: (v: string) => void; }> = { cx: { @@ -1760,10 +1780,16 @@ export function Workspace() { style: { label: 'PHOTO STYLE', off: 'sim-provia', value: simId && FILM_SIMS.some((s) => s.id === simId) ? simId : '', - options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name })), + options: FILM_SIMS.map((s) => ({ v: s.id, d: s.name, pro: PRO_SIMS.includes(s.id) })), onPick: (v) => { const sim = FILM_SIMS.find((s) => s.id === v); if (!sim) return; + // The newest three looks are the account's; the strip shows the badge + // and the click says why (see the PRO gate at the top of the file). + if (PRO_SIMS.includes(sim.id) && !pro) { + promptPro(); + return; + } setShowRecipes(false); applyRecipe(filmSimToRecipe(sim)); }, @@ -1840,7 +1866,7 @@ export function Workspace() { const choiceChips = ( prefix: string, - options: { v: string; d: string }[], + options: { v: string; d: string; pro?: boolean }[], current: string, onPick: (v: string) => void ): ChipDef[] => @@ -1848,6 +1874,7 @@ export function Workspace() { key: `${prefix}:${o.v}`, label: o.d, active: current === o.v, + pro: o.pro, onClick: () => onPick(o.v), })); @@ -2068,6 +2095,10 @@ export function Workspace() { ...paramChips(PARAM_DEFS.filters), ]; case 'hsl': { + // The mixer is PRO: the picker, the eight bands and the three + // image-wide knobs all come with the account, so a guest is offered the + // reason rather than a wall of dead chips. + if (!pro) return [{ key: 'hsl-pro', label: t('pro.hslChip'), pro: true, onClick: promptPro }]; // PICK arms the eyedropper; the eight band chips choose which band the // ruler edits, and the ruler itself is opened by the three knobs below. // Those three are the image's own move, not the band's: the band chips @@ -2321,7 +2352,7 @@ export function Workspace() { {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. */} - {tab === 'hsl' ? ( + {tab === 'hsl' && pro ? (
{sample && picked ? ( <> diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts index 4ae6e82..13d2a97 100644 --- a/docker/frontend/src/i18n/en.ts +++ b/docker/frontend/src/i18n/en.ts @@ -128,6 +128,7 @@ export const en: Dict = { // The PRO gate. Signed in but unproven is served as a guest, so the studio // needs one place that says why and one way to ask for the letter again. 'pro.verifyChip': 'VERIFY EMAIL TO USE PRO', + 'pro.hslChip': 'HSL IS A PRO FEATURE', 'pro.unverifiedBar': 'Account {email} is not verified — it works like a guest.', 'pro.resend': 'RESEND VERIFICATION EMAIL', 'pro.resent': 'SENT', diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts index f458e95..fd604b1 100644 --- a/docker/frontend/src/i18n/vi.ts +++ b/docker/frontend/src/i18n/vi.ts @@ -136,6 +136,7 @@ export const vi = { // The PRO gate. Signed in but unproven is served as a guest, so the studio // needs one place that says why and one way to ask for the letter again. 'pro.verifyChip': 'XÁC THỰC EMAIL ĐỂ DÙNG PRO', + 'pro.hslChip': 'HSL LÀ TÍNH NĂNG PRO', 'pro.unverifiedBar': 'Tài khoản {email} chưa xác thực email — đang dùng như khách.', 'pro.resend': 'GỬI LẠI EMAIL XÁC THỰC', 'pro.resent': 'ĐÃ GỬI', diff --git a/docker/frontend/src/styles/app.css b/docker/frontend/src/styles/app.css index 64f1fba..d6ce8ba 100644 --- a/docker/frontend/src/styles/app.css +++ b/docker/frontend/src/styles/app.css @@ -394,16 +394,14 @@ input[type="range"] { width: 100%; accent-color: var(--accent); } pointer-events: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--bg-elev) 90%, transparent); - backdrop-filter: blur(6px); - box-shadow: var(--shadow); + background: transparent; user-select: none; touch-action: none; } -.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); } +.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); text-shadow: 0 1px 2px var(--bg); } .straighten-head b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); font-variant-numeric: tabular-nums; } .straighten-head b.amber { color: var(--accent); } -.straighten-scale { position: relative; height: 30px; cursor: ew-resize; } +.straighten-scale { position: relative; height: 30px; cursor: ew-resize; filter: drop-shadow(0 0 2px var(--bg)); } .straighten-tick { position: absolute; bottom: 12px; width: 1px; height: 7px; background: var(--border); transform: translateX(-50%); } .straighten-tick.major { height: 12px; background: var(--text-dim); } .straighten-tick.zero { height: 16px; background: var(--text); } diff --git a/docker/frontend/src/ui/StraightenRuler.tsx b/docker/frontend/src/ui/StraightenRuler.tsx index 65d6793..5bde40c 100644 --- a/docker/frontend/src/ui/StraightenRuler.tsx +++ b/docker/frontend/src/ui/StraightenRuler.tsx @@ -1,10 +1,14 @@ -import { useRef } from 'react'; +import { useEffect, useRef } from 'react'; // FRAME > ROTATE > STRAIGHTEN's ruler is not one of the chip row's stacked // knobs: it is a graduated scale lying across the foot of the photo, so the // angle is read off the picture it is levelling. Dragging anywhere on the scale -// turns the photo by whole degrees; leaving the tool (another tab or button) -// keeps the angle and takes the scale away — see App's dismiss effect. +// turns the photo by whole degrees, and the wheel does the same one degree a +// notch while the pointer is on the ruler — the photo underneath stays visible +// through it (see `.straighten-ruler` in styles/app.css), because the whole +// point of the scale is to be read against the frame it is levelling. Leaving +// the tool (another tab or button) keeps the angle and takes the scale away — +// see App's dismiss effect. const RANGE = 180; // degrees either side of 0, the whole circle across the width const TICK = 5; // minor tick spacing const MAJOR = 45; // labelled tick spacing @@ -19,7 +23,27 @@ export function StraightenRuler({ onReset: () => void; }) { const scaleRef = useRef(null); + const rootRef = useRef(null); const dragging = useRef(false); + const live = useRef({ value, onChange }); + live.current = { value, onChange }; + + // The wheel belongs to the ruler while the pointer is on it: one notch is one + // degree. The listener is native because React's is passive, and the notch + // must not reach the stage — whose own wheel zooms the photo under it. + useEffect(() => { + const el = rootRef.current; + if (!el) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + e.stopPropagation(); + const s = live.current; + const next = Math.max(-RANGE, Math.min(RANGE, s.value + (e.deltaY < 0 ? 1 : -1))); + if (next !== s.value) s.onChange(next); + }; + el.addEventListener('wheel', onWheel, { passive: false }); + return () => el.removeEventListener('wheel', onWheel); + }, []); // A pixel of travel is well under a degree at any sane width, so the reading // snaps to whole degrees however far the pointer has been dragged. @@ -35,7 +59,7 @@ export function StraightenRuler({ const pctOf = (d: number) => ((d + RANGE) / (2 * RANGE)) * 100; return ( -
e.stopPropagation()}> +
e.stopPropagation()}>
STRAIGHTEN