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