web: accent the brand and add the studio's quick gestures

- the brand "Cam", the avatar and the signed-in name follow the theme accent
- double-click a slider track resets that parameter; double-click the photo
  toggles 1:1 and the whole photo on the stage
- `*`, or a recipe chip dragged onto the star, files the look under FAVORITED
- RESET under CREATE clears the draft form, and the button now sits under a
  rule at the foot of the column
This commit is contained in:
2026-09-18 12:09:06 +07:00
parent 0f52572f20
commit 567f650bbb
6 changed files with 144 additions and 9 deletions
+15
View File
@@ -14,6 +14,9 @@ export interface ChipDef {
amberValue?: boolean;
pro?: boolean;
disabled?: 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;
onClick: () => void;
}
@@ -31,6 +34,8 @@ export interface SliderDef {
// 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.
onBack: () => void;
}
@@ -46,6 +51,15 @@ export function ChipRow({ chips }: { chips: ChipDef[] }) {
className={`chip${chip.active ? ' on' : ''}${chip.amberValue ? ' amber' : ''}`}
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
}
onClick={chip.onClick}
>
{chip.label}
@@ -92,6 +106,7 @@ export function SliderRow({ slider }: { slider: SliderDef }) {
step={slider.step}
value={slider.value}
onChange={(e) => slider.onChange(Number(e.target.value))}
onDoubleClick={slider.onReset}
/>
{slider.swatch ? (
<div
+18
View File
@@ -393,6 +393,24 @@ export function ImageStage({
onPointerMove={movePan}
onPointerUp={endPan}
onPointerCancel={endPan}
// Double click toggles the two sizes worth having: 1:1 (one photo
// pixel per CSS pixel) and the whole photo back on the stage. The
// crop editor owns the gesture instead — its wheel tightens the
// frame, so the view stays put.
onDoubleClick={() => {
if (cropEditing) return;
const v = viewRef.current;
if (v.s !== 1) {
setView({ s: 1, x: 0, y: 0 });
return;
}
const b = baseRect();
const nw = imgRef.current?.naturalWidth ?? 0;
if (!b || !(nw > 0)) return;
const s = Math.min(ZOOM_MAX, Math.max(1, nw / b.w));
if (s === 1) return;
setView({ s, ...clampPan(s, 0, 0) });
}}
style={
view.s === 1
? undefined
@@ -58,10 +58,14 @@ export function RecipeCreatePanel({
onPreview,
onSave,
onExport,
resetToken = 0,
}: {
currentBase?: BaseFilter;
current?: ColorAdjustments | null;
signedIn: boolean;
// Bumped by the RESET chip above the form: the draft drops back to the state
// the form opened with.
resetToken?: number;
onPreview: (draft: Pick<Recipe, 'baseFilter' | 'adjustments'>) => void;
onSave: (draft: RecipeDraft) => void;
onExport: (draft: RecipeDraft) => void;
@@ -155,6 +159,17 @@ export function RecipeCreatePanel({
return { name: name.trim() || 'NEW RECIPE', baseFilter: sim, adjustments, frameId: 'none', useGeotag: true };
};
// RESET clears the draft: back to the seed simulation, its standard values and
// an empty name. The form previews live, so the photo follows it home.
useEffect(() => {
if (resetToken === 0) return;
selectSim(seedBase);
setName('');
setWbRed(String(seedAdj.wbRed ?? 0));
setWbBlue(String(seedAdj.wbBlue ?? 0));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetToken]);
// Realtime: every knob applies to the photo on screen as it is typed, so the
// effect of the recipe is visible before saving it. Opening the tab is not an
// edit — the first run (the seeded values) is skipped.
+24 -1
View File
@@ -17,8 +17,18 @@ export const TABS: { id: TabId; label: MsgKey; glyph: string }[] = [
{ id: 'create', label: 'tab.create', glyph: '✎' },
];
export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => void }) {
export function ToolRail({
tab,
onTab,
onDropRecipe,
}: {
tab: TabId;
onTab: (tab: TabId) => void;
// A recipe chip dragged onto FAVORITED is filed there (see App's favorite()).
onDropRecipe?: (id: string) => void;
}) {
const { t } = useI18n();
const favorited = (id: TabId) => id === 'favorited';
return (
<nav className="rail" aria-label="tool tabs">
{TABS.map((entry) => (
@@ -27,6 +37,19 @@ export function ToolRail({ tab, onTab }: { tab: TabId; onTab: (tab: TabId) => vo
type="button"
aria-pressed={tab === entry.id}
onClick={() => onTab(entry.id)}
// The star is a drop target: recipes are dragged onto it from the
// RECIPES strip rather than retyped.
onDragOver={(e) => {
if (!favorited(entry.id) || !onDropRecipe) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
}}
onDrop={(e) => {
if (!favorited(entry.id) || !onDropRecipe) return;
e.preventDefault();
const id = e.dataTransfer.getData('text/plain');
if (id) onDropRecipe(id);
}}
>
<span className="glyph" aria-hidden="true">{entry.glyph}</span>
{t(entry.label)}