Files
RecipesCam/src/components/AdjustmentPanel.tsx
T
admin d7a8e0681e Add CANCEL to the watermark editor, scope the mark's touch layer to that editor, and stop a library frame following the user onto the camera
The custom watermark captured EVERY image touch whenever the chip was ON
with text, in both modes: after switching from a library photo to the
camera there was no way to give the image back to tap-to-focus, and a
frame picked for the still rode along onto the live preview (and into the
capture).

- AdjustPanel: CANCEL chip next to ACCEPT. It drops the un-accepted draft
  and restores the last ACCEPTED mark (none if nothing was accepted).
- Viewfinder: draws the mark whenever the chip is ON with text, but only
  lets it CAPTURE touches while its editor is open (new wmEditing prop).
  A double-tap ON the mark still re-opens that editor from the library
  viewer.
- App: ACCEPT and CANCEL now close the editor (that closes the placement
  layer with it). Switching mode closes the deck panels, and leaves the
  frame behind: camera mode has no FRAME picker to undo one.

Verified on device (9a6a7277): camera tap locks AE/AF with the watermark
left ON; library polaroid no longer appears on the live preview after the
switch; CANCEL reverts the chip to CUSTOM WATERMARK OFF and closes the
panel; ACCEPT closes it and the next capture embeds the mark (amber text
found at 0.5/0.375 of the 3000x4000 export), while a capture before
ACCEPT carries none.
2026-09-10 09:58:50 +07:00

671 lines
24 KiB
TypeScript

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, ScrollView } from 'react-native';
import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, FrameId, BaseFilter } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
import RecipeCreateModal from './RecipeCreateModal';
// Compact bottom sheet above the tool rail:
// bottom row = the tool's chips (presets / parameters), horizontally scrollable
// rows above = one full-width slider per OPEN parameter, labeled with its name.
// Tapping a parameter chip toggles its slider row; chips stay visible.
// Darkroom-flavoured: amber = active/edited, red = destructive, mono labels.
type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame' | 'watermark';
// Choice-group keys that open a strip above the chip row: color chrome groups
// (wb tab) and the D.RANGE selector (iq tab).
type GroupKey = 'cx' | 'cxb' | 'dr' | 'wmColor' | 'wmSize' | 'wmFont';
interface AdjustmentPanelProps {
activeTab: TabId;
recipes: Recipe[];
currentRecipeId: string;
adjustments: ColorAdjustments;
selectedFrame: FrameId;
useGeotag: boolean;
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
// Settings sheet (gear between library picker and shutter) — no longer
// tool-rail tabs.
// The parameter whose slider row is open — lifted to App so it can also drive
// drag-anywhere-on-the-image adjustment while the row is open.
openParam: string | null;
onOpenParamChange: (key: string | null) => void;
// Base filter of the currently applied recipe — seeds the CREATE modal's
// SIMULATION row so quick-input starts from the active look.
currentBaseFilter?: BaseFilter;
// CREATE quick-input saves a brand-new recipe (name + adjustments from the
// modal) and applies it, unlike onSaveRecipe which snapshots the live state.
onCreateRecipe: (recipe: Omit<Recipe, 'id' | 'isCustom'>) => void;
onSelectRecipe: (recipe: Recipe) => void;
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
onUpdateFrame: (frameId: FrameId) => void;
// FRAME tab: turn the inserted library photo 90deg (landscape <-> portrait)
// so it fits the chosen frame better.
photoRotation: 0 | 90;
onToggleRotation: () => void;
onToggleGeotag: (enabled: boolean) => void;
onSaveRecipe: (name: string) => void;
onDeleteRecipe: (id: string) => void;
// WATERMARK tab state — lifted to App so the live Viewfinder overlay, this
// input and the export options all read the same object.
customWm: CustomWatermark;
onUpdateCustomWm: (wm: CustomWatermark) => void;
// ACCEPT: copy the live draft into the mark the export burns in. Edits stay a
// draft (viewfinder preview only) until this is pressed.
onAcceptCustomWm: () => void;
// CANCEL: throw the un-accepted draft away — back to the mark that was last
// ACCEPTED (or none at all) — and close the editor. The way out of a running
// watermark edit that previously did not exist.
onCancelCustomWm: () => void;
// Bumped by a double-tap on the mark: put the caret back in the text input.
wmEditNonce: number;
}
interface ChipDef {
key: string;
label: string;
active: boolean;
onPress: () => void;
onPressDelete?: () => void; // custom presets carry an inline trash button
trash?: boolean;
disabled?: boolean; // informational label, not tappable
amberValue?: boolean; // amber text driven by edited value, not selection
}
interface ParamDef {
key: string;
label: string;
value: number;
default: number;
min: number;
max: number;
step: number;
display: (v: number) => string;
onChange: (v: number) => void;
}
const haptic = () => Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// WB preset → engine pair. AUTO and DAYLIGHT both land on the neutral
// 5500K / 0 pair (the engine has no scene meter), so the pair alone cannot say
// which chip is active — the last tapped preset is remembered to disambiguate.
const WB_PAIRS: Record<string, { t: number; ti: number }> = {
auto: { t: 5500, ti: 0 },
daylight: { t: 5500, ti: 0 },
'daylight-3r': { t: 5500, ti: -3 },
cloudy: { t: 6500, ti: 1 },
shade: { t: 8000, ti: 2 },
tungsten: { t: 3200, ti: 0 },
fluorescent: { t: 4000, ti: 3 },
};
// WATERMARK tab styling choices: swatches for TEXT COLOR, multipliers on the
// base 3.2%-of-frame-width size for TEXT SIZE.
const WM_DEFAULT_COLOR = '#f59e0b';
const WM_COLORS: { v: string; d: string }[] = [
{ v: '#f59e0b', d: 'AMBER' },
{ v: '#ffffff', d: 'WHITE' },
{ v: '#111111', d: 'BLACK' },
{ v: '#ef4444', d: 'RED' },
{ v: '#3b82f6', d: 'BLUE' },
{ v: '#22c55e', d: 'GREEN' },
{ v: '#a1a1aa', d: 'GREY' },
];
const WM_SIZES: { v: string; d: string }[] = [
{ v: '0.6', d: 'S' },
{ v: '1', d: 'M' },
{ v: '1.6', d: 'L' },
{ v: '2.5', d: 'XL' },
];
export default function AdjustmentPanel({
activeTab,
recipes,
currentRecipeId,
adjustments,
selectedFrame,
useGeotag,
openParam,
onOpenParamChange,
currentBaseFilter,
onCreateRecipe,
onSelectRecipe,
onUpdateAdjustments,
onUpdateFrame,
photoRotation,
onToggleGeotag,
onDeleteRecipe,
customWm,
onUpdateCustomWm,
onAcceptCustomWm,
onCancelCustomWm,
wmEditNonce,
onToggleRotation,
}: AdjustmentPanelProps) {
// Double-tap on the mark in the viewfinder bumps wmEditNonce -> focus the
// input again so the text can be retyped after the keyboard was dismissed.
const wmInputRef = useRef<TextInput>(null);
useEffect(() => {
if (wmEditNonce > 0) {
// BACK hides the IME but leaves the input focused, so a bare focus() is a
// no-op and the keyboard never returns. Blur first so the re-focus is a
// real focus change the IME reacts to.
wmInputRef.current?.blur();
const id = setTimeout(() => wmInputRef.current?.focus(), 60);
return () => clearTimeout(id);
}
}, [wmEditNonce]);
// The single choice-group whose strip (OFF/WEAK/STRONG, or the DR ladder) is
// open above the chips (same one-row-at-a-time pattern as openParam — which
// lives in App now, since an open row also arms image-drag adjustment).
const [openGroup, setOpenGroup] = useState<GroupKey | null>(null);
// PRESETS tab only: RECIPES chip expands the user-recipe strip above the main
// row; CREATE chip opens the quick-input modal.
const [showRecipes, setShowRecipes] = useState(false);
const [createVisible, setCreateVisible] = useState(false);
// WB tab: preset last tapped while its engine pair is still in effect (AUTO
// vs DAYLIGHT share the neutral pair, so selection can't be derived).
const [wbChoice, setWbChoice] = useState<string | null>(null);
useEffect(() => {
setOpenGroup(null);
setShowRecipes(false);
setCreateVisible(false);
}, [activeTab]);
const toggleParam = (key: string) => {
haptic();
// One auxiliary row at a time: opening a slider closes any option strip.
setOpenGroup(null);
onOpenParamChange(openParam === key ? null : key);
};
const toggleGroup = (g: GroupKey) => {
haptic();
const next = openGroup === g ? null : g;
// Opening an options strip closes any open slider row (single-row rule).
if (next) onOpenParamChange(null);
setOpenGroup(next);
};
const a = adjustments;
const renderChip = (c: ChipDef) => {
const tone = c.active
? 'border-amber-500/70 bg-amber-500/15'
: 'border-zinc-700/60 bg-titan/80';
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
return (
<View key={c.key} className="mx-1">
{c.trash ? (
<View className={`flex-row items-center rounded-full border py-1.5 pl-3 pr-1 ${tone}`}>
<TouchableOpacity onPress={() => { haptic(); c.onPress(); }} activeOpacity={0.7}>
<Text className={`font-mono text-[11px] font-bold ${textTone}`}>{c.label}</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
haptic();
c.onPressDelete && c.onPressDelete();
}}
className="p-1.5 ml-1"
hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }}
>
<Trash2 size={12} color="#ef4444" />
</TouchableOpacity>
</View>
) : c.disabled ? (
<View className="mx-1 flex-row items-center rounded-full border border-zinc-800/80 bg-black/30 px-3 py-1.5">
<Text className="font-mono text-[10px] font-semibold text-zinc-500">{c.label}</Text>
</View>
) : (
<TouchableOpacity
onPress={() => { haptic(); c.onPress(); }}
className={`flex-row items-center rounded-full border px-3 py-1.5 ${tone}`}
activeOpacity={0.7}
>
<Text className={`font-mono text-[11px] font-bold tracking-wide ${textTone}`}>
{c.label}
</Text>
</TouchableOpacity>
)}
</View>
);
};
// Continuous parameter chips: label + live value; amber text when edited;
// amber border while its slider row is open.
const paramChips = (params: ParamDef[]) =>
params.map((p) =>
renderChip({
key: p.key,
label: `${p.label.toUpperCase()} ${p.display(p.value)}`,
active: openParam === p.key,
amberValue: openParam !== p.key && p.value !== p.default,
onPress: () => toggleParam(p.key),
})
);
const choiceChips = (
keyPrefix: string,
options: { v: string; d: string }[],
current: string,
onPick: (v: string) => void
) =>
options.map((o) =>
renderChip({
key: `${keyPrefix}:${o.v}`,
label: o.d,
active: current === o.v,
onPress: () => onPick(o.v),
})
);
// ---- per-tab parameter definitions ---------------------------------------
// Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which
// needs the same metadata to drive drag-anywhere adjustment); here each row
// is bound to the live adjustments value + an update callback.
const toRows = (defs: ContinuousParamDef[]): ParamDef[] =>
defs.map((d) => ({
key: d.key,
label: d.label,
value: d.get(a),
default: d.defaultValue,
min: d.min,
max: d.max,
step: d.step,
display: d.display,
onChange: (v: number) => onUpdateAdjustments(d.set(v)),
}));
const paramDefs: Record<TabId, ParamDef[]> = {
recipes: [],
iq: toRows(PARAM_DEFS.iq),
wb: toRows(PARAM_DEFS.wb),
filters: toRows(PARAM_DEFS.filters),
frame: [],
watermark: [],
};
// Active WB chip: the remembered preset wins while the engine pair still
// matches it; otherwise derive from the pair (neutral ⇒ AUTO; any unlisted
// pair also reads as AUTO — no preset is a stronger claim there).
const wbActive = (): string => {
const t = a.temperature ?? 5500;
const ti = a.tint ?? 0;
if (wbChoice) {
const p = WB_PAIRS[wbChoice];
if (p && p.t === t && p.ti === ti) return wbChoice;
}
const hit = (Object.keys(WB_PAIRS) as string[]).find((k) => {
const p = WB_PAIRS[k];
return p.t === t && p.ti === ti;
});
return hit ?? 'auto';
};
const wbApply = (val: string) => {
setWbChoice(val);
// Choosing a preset is a deliberate look switch: collapse any open chrome
// options strip so it never lingers above the chips row.
setOpenGroup(null);
const p = WB_PAIRS[val] ?? WB_PAIRS.auto;
onUpdateAdjustments({ temperature: p.t, tint: p.ti });
};
// Choice-groups: one toggle chip sits in the chip row (COLOR CHROME /
// CHROME BLUE among the WB chips, D.RANGE after the LIGHT params); tapping it
// reveals a strip above the chip row with that group's options. Amber text
// while a value differs from the group's off value, amber border while its
// strip is open — same affordances as the parameter chips.
const chromeOptions = [
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
];
const drOptions = [
{ v: 'auto', d: 'AUTO' },
{ v: '100', d: 'DR100' },
{ v: '200', d: 'DR200' },
{ v: '400', d: 'DR400' },
];
// WATERMARK tab styling strips. FONT lists every family the PLATFORM font
// manager exposes (the device's own system fonts); DEFAULT keeps the face
// the mark shipped with.
const wmFontOptions = useMemo(() => {
const mgr = Skia.FontMgr.System();
const names = new Set<string>();
for (let i = 0; i < mgr.countFamilies() && names.size < 120; i++) {
const n = mgr.getFamilyName(i);
if (n && !/fallback/i.test(n)) names.add(n);
}
return [
{ v: 'DEFAULT', d: 'DEFAULT' },
...[...names].sort().map((n) => ({ v: n, d: n })),
];
}, []);
const groupDefs: Record<GroupKey, { label: string; off: string; value: string; options: { v: string; d: string }[]; onPick: (v: string) => void }> = {
cx: {
label: 'COLOR CHROME', off: 'none', value: a.colorChrome || 'none', options: chromeOptions,
onPick: (v) => onUpdateAdjustments({ colorChrome: v as ColorAdjustments['colorChrome'] }),
},
cxb: {
label: 'CHROME BLUE', off: 'none', value: a.colorChromeBlue || 'none', options: chromeOptions,
onPick: (v) => onUpdateAdjustments({ colorChromeBlue: v as ColorAdjustments['colorChromeBlue'] }),
},
dr: {
label: 'D.RANGE', off: 'auto', value: String(a.dynamicRange ?? 'auto'), options: drOptions,
onPick: (v) =>
onUpdateAdjustments({
dynamicRange: (v === 'auto' ? 'auto' : parseInt(v, 10)) as ColorAdjustments['dynamicRange'],
}),
},
wmColor: {
label: 'TEXT COLOR', off: WM_DEFAULT_COLOR,
value: customWm.color ?? WM_DEFAULT_COLOR, options: WM_COLORS,
onPick: (v) => onUpdateCustomWm({ ...customWm, color: v }),
},
wmSize: {
label: 'TEXT SIZE', off: '1', value: String(customWm.size ?? 1), options: WM_SIZES,
onPick: (v) => onUpdateCustomWm({ ...customWm, size: Number(v) }),
},
wmFont: {
label: 'TEXT FONT', off: 'DEFAULT', value: customWm.font ?? 'DEFAULT',
options: wmFontOptions,
onPick: (v) => onUpdateCustomWm({ ...customWm, font: v === 'DEFAULT' ? null : v }),
},
};
const groupChip = (g: GroupKey): ChipDef => ({
key: `grp-${g}`,
label: groupDefs[g].label,
active: openGroup === g,
amberValue: openGroup !== g && groupDefs[g].value !== groupDefs[g].off,
onPress: () => toggleGroup(g),
});
// ---- per-tab chip rows (bottom, above the tool rail) -----------------------
const chipsRow = (() => {
switch (activeTab) {
case 'recipes': {
// Main row: the six standard film simulations, then the RECIPES toggle
// (expands the user/bundled recipe strip above) and CREATE (quick-input
// modal). Recipes themselves are no longer inline chips here.
const chips: ChipDef[] = [
...FILM_SIMS.map((s) => ({
key: s.id,
label: s.name,
active: currentRecipeId === s.id,
// Choosing a simulation is a deliberate switch away from the saved
// recipes: collapse the RECIPES strip above so it never lingers
// over the freshly-applied sim.
onPress: () => {
setShowRecipes(false);
onSelectRecipe(filmSimToRecipe(s));
},
})),
{
key: 'recipes-toggle',
label: 'RECIPES',
active: showRecipes,
onPress: () => setShowRecipes((v) => !v),
},
{
key: 'create',
label: 'CREATE',
active: false,
onPress: () => setCreateVisible(true),
},
];
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{chips.map(renderChip)}
</ScrollView>
);
}
case 'iq':
// LIGHT: continuous params on one row; D.RANGE is a toggle chip that
// opens its AUTO/DR100/DR200/DR400 strip above (same as the chrome
// groups on WB).
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{paramChips(paramDefs.iq)}
{renderChip(groupChip('dr'))}
</ScrollView>
);
case 'wb':
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{choiceChips(
'wb',
[
{ v: 'auto', d: 'AUTO' },
{ v: 'daylight', d: 'DAYLIGHT' },
{ v: 'daylight-3r', d: 'DAYLIGHT -3R' },
{ v: 'cloudy', d: 'CLOUDY' },
{ v: 'shade', d: 'SHADE' },
{ v: 'tungsten', d: 'TUNGSTEN' },
{ v: 'fluorescent', d: 'FLUOR' },
],
wbActive(),
wbApply
)}
{paramChips(paramDefs.wb)}
{(['cx', 'cxb'] as const).map((g) => renderChip(groupChip(g)))}
</ScrollView>
</View>
);
case 'filters':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{paramChips(paramDefs.filters)}
</ScrollView>
);
case 'frame':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{choiceChips(
'fr',
FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase() })),
selectedFrame,
(val) => onUpdateFrame(val as FrameId)
)}
{renderChip({
key: 'rotate',
label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0',
active: photoRotation !== 0,
onPress: onToggleRotation,
})}
</ScrollView>
);
case 'watermark':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{renderChip({
key: 'gps',
label: useGeotag ? 'GPS WATERMARK ON' : 'GPS WATERMARK OFF',
active: useGeotag,
onPress: () => onToggleGeotag(!useGeotag),
})}
{renderChip({
key: 'custom-wm',
label: customWm.enabled ? 'CUSTOM WATERMARK ON' : 'CUSTOM WATERMARK OFF',
active: customWm.enabled,
onPress: () => onUpdateCustomWm({ ...customWm, enabled: !customWm.enabled }),
})}
{customWm.enabled &&
renderChip({
key: 'custom-wm-accept',
label: 'ACCEPT',
active: true,
onPress: onAcceptCustomWm,
})}
{customWm.enabled &&
renderChip({
key: 'custom-wm-cancel',
label: 'CANCEL',
active: false,
onPress: onCancelCustomWm,
})}
{customWm.enabled && renderChip(groupChip('wmColor'))}
{customWm.enabled && renderChip(groupChip('wmSize'))}
{customWm.enabled && renderChip(groupChip('wmFont'))}
</ScrollView>
);
default:
return null;
}
})();
// ---- the single open slider row (ABOVE the chips row) ----------------------
const openParamDef = paramDefs[activeTab].find((p) => p.key === openParam) ?? null;
return (
<View>
{openParamDef && (
<View
key={openParamDef.key}
className="flex-row items-center border-b border-zinc-800/60 px-3 py-1.5"
>
<TouchableOpacity
onPress={() => toggleParam(openParamDef.key)}
className="mr-3 rounded-full border border-zinc-700 bg-titan px-3 py-2"
activeOpacity={0.7}
>
<Text className="font-mono text-xs font-bold text-zinc-300">&lt;</Text>
</TouchableOpacity>
<View className="flex-1">
<View className="flex-row justify-between items-center mb-0.5">
<Text className="text-zinc-400 font-mono text-[10px] font-bold tracking-widest">
{openParamDef.label}
</Text>
<Text
className={`font-mono text-xs font-bold ${
openParamDef.value !== openParamDef.default ? 'text-amber-500' : 'text-zinc-300'
}`}
>
{openParamDef.display(openParamDef.value)}
</Text>
</View>
<Slider
value={openParamDef.value}
minimumValue={openParamDef.min}
maximumValue={openParamDef.max}
step={openParamDef.step}
onValueChange={openParamDef.onChange}
onSlidingComplete={haptic}
minimumTrackTintColor="#f59e0b"
maximumTrackTintColor="#27272a"
thumbTintColor="#f59e0b"
/>
</View>
</View>
)}
{openGroup && (
<View className="border-b border-zinc-800/60">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{renderChip({
key: `grp-hint-${openGroup}`,
label: groupDefs[openGroup].label,
disabled: true,
active: false,
onPress: () => {},
})}
{choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick)}
</ScrollView>
</View>
)}
{/* PRESETS: expanded recipe strip (above the main chip row) while the
RECIPES chip is open — bundled defaults + user-created, custom
deletable inline. */}
{activeTab === 'recipes' && showRecipes && (
<View className="border-b border-zinc-800/60">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{recipes.length === 0
? renderChip({
key: 'no-recipes',
label: 'NO RECIPES YET - TAP CREATE',
disabled: true,
active: false,
onPress: () => {},
})
: recipes.map((r) =>
renderChip({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
trash: !!r.isCustom,
onPress: () => onSelectRecipe(r),
onPressDelete: () => onDeleteRecipe(r.id),
})
)}
</ScrollView>
</View>
)}
{activeTab === 'watermark' && customWm.enabled && (
<View className="border-b border-amber-500/30 bg-amber-500/5 px-3 py-2">
<TextInput
ref={wmInputRef}
value={customWm.text}
onChangeText={(text) => onUpdateCustomWm({ ...customWm, text })}
placeholder="TYPE WATERMARK TEXT"
placeholderTextColor="#a1a1aa"
className="rounded-md border border-amber-500/70 bg-black/40 px-3 py-2 font-mono text-sm font-bold text-amber-300"
maxLength={80}
/>
</View>
)}
{chipsRow}
<RecipeCreateModal
visible={createVisible}
currentBase={currentBaseFilter}
onClose={() => setCreateVisible(false)}
onSave={(recipe) => {
onCreateRecipe(recipe);
setShowRecipes(true);
}}
/>
</View>
);
}