// app/screen-settings.jsx — Avatar ▸ Settings. Unit system, default planning // servings, and the ingredient-price DB (the prices the cost engine uses). const setLabel = { fontFamily: 'Bricolage Grotesque', fontSize: 10, fontWeight: 600, letterSpacing: '0.24em', textTransform: 'uppercase', color: 'var(--ink-3)' }; const setInput = { width: '100%', padding: '11px 13px', borderRadius: 9, background: 'rgba(0,0,0,0.3)', border: '1px solid var(--hairline)', color: 'var(--ink)', fontFamily: 'Bricolage Grotesque', fontSize: 14, outline: 'none', boxSizing: 'border-box' }; function Segmented({ options, value, onChange }) { return (
{options.map(([v, label]) => { const on = v === value; return ( ); })}
); } function SettingsCard({ children, style }) { return
{children}
; } function SettingsRow({ title, sub, children }) { return (
{title}
{sub &&
{sub}
}
{children}
); } const blankItem = () => ({ key: '', name: '', unit: 'g', packSize: '', packCost: '', isStaple: false, aliases: '', source: 'You' }); function IngredientEditor({ item, onSave, onCancel, isMobile }) { const [f, setF] = React.useState(() => ({ ...item, packSize: item.packSize ?? '', packCost: item.packCost ?? '', aliases: Array.isArray(item.aliases) ? item.aliases.join(', ') : (item.aliases || '') })); const upd = (k, v) => setF((p) => ({ ...p, [k]: v })); const valid = f.name.trim() && Number(f.packSize) > 0 && Number(f.packCost) >= 0; const save = () => onSave({ key: f.key || undefined, name: f.name.trim(), unit: f.unit, packSize: Number(f.packSize), packCost: Number(f.packCost), isStaple: !!f.isStaple, aliases: f.aliases.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean), source: f.source || 'You' }); return (
upd('name', e.target.value)} placeholder="Olive oil" style={{ ...setInput, marginTop: 6 }} />
upd('packSize', e.target.value)} placeholder="750" style={{ ...setInput, marginTop: 6 }} />
upd('packCost', e.target.value)} placeholder="9.00" style={{ ...setInput, marginTop: 6 }} />
upd('aliases', e.target.value)} placeholder="olive oil, evoo" style={{ ...setInput, marginTop: 6 }} />
{!isMobile && } {Number(f.packSize) > 0 && Number(f.packCost) >= 0 && ( = {window.LCDomain.fmtMoney(Number(f.packCost) / Number(f.packSize))} / {f.unit} )}
Save Cancel
); } function IngredientRow({ item, onEdit, onDelete, selected, onToggleSelect, isMobile }) { const unitCost = item.packSize ? item.packCost / item.packSize : (item.unitCost || 0); const checkbox = ( ); const nameLine = (
{item.name} {item.isStaple && staple}
); const sub = (
{window.LCDomain.fmtMoney(item.packCost)} / {item.packSize}{item.unit} · {item.source || 'AI'}{item.date ? ` · ${item.date}` : ''}
); const unit = ( {window.LCDomain.fmtMoney(unitCost)}/{item.unit} ); const editBtn = ( ); const delBtn = ( ); if (isMobile) { return (
{checkbox}
{nameLine}
{unit}
{sub}
{editBtn}{delBtn}
); } return (
{checkbox}
{nameLine}{sub}
{unit}{editBtn}{delBtn}
); } function MergeDialog({ items, onConfirm, onClose, isMobile }) { const [primaryKey, setPrimaryKey] = React.useState(items[0] ? items[0].key : null); return (
e.stopPropagation()} style={{ width: '100%', maxWidth: isMobile ? '100%' : 460, maxHeight: isMobile ? '92%' : '90%', overflowY: 'auto', background: 'rgba(20,20,24,0.98)', border: '1px solid var(--hairline)', borderRadius: isMobile ? '22px 22px 0 0' : 20, boxShadow: '0 40px 100px rgba(0,0,0,0.7)', padding: isMobile ? '22px 20px 28px' : 26, boxSizing: 'border-box' }}>
Merge {items.length} ingredients

Keep one — fold the rest in.

The others' names and aliases become aliases of the one you keep, then they're removed. Recipes re-match to the kept item's price.

{items.map((it) => { const on = it.key === primaryKey; const unitCost = it.packSize ? it.packCost / it.packSize : (it.unitCost || 0); return ( ); })}
Cancel onConfirm(primaryKey)} style={isMobile ? { flex: 1.4 } : {}}>Merge into one
); } function SettingsScreen() { const { layout, back, settings, setSettings, ingredientCosts, saveIngredientCost, deleteIngredientCost } = useApp(); const isMobile = layout === 'mobile'; const [q, setQ] = React.useState(''); const [editing, setEditing] = React.useState(null); // key being edited, or '__new__' const [selected, setSelected] = React.useState(() => new Set()); const [merging, setMerging] = React.useState(false); const list = (ingredientCosts || []).filter((it) => !q || (it.name + ' ' + (it.aliases || []).join(' ')).toLowerCase().includes(q.toLowerCase())) .sort((a, b) => (a.name || '').localeCompare(b.name || '')); const onSave = async (item) => { await saveIngredientCost(item); setEditing(null); }; const toggleSel = (key) => setSelected((s) => { const n = new Set(s); n.has(key) ? n.delete(key) : n.add(key); return n; }); const clearSel = () => setSelected(new Set()); const selectedItems = (ingredientCosts || []).filter((it) => selected.has(it.key)); const doMerge = async (primaryKey) => { const primary = selectedItems.find((i) => i.key === primaryKey); if (!primary) { setMerging(false); return; } const others = selectedItems.filter((i) => i.key !== primaryKey); const aliasSet = new Set((primary.aliases || []).map((a) => a.toLowerCase())); others.forEach((o) => { (o.aliases || []).forEach((a) => aliasSet.add(a.toLowerCase())); if (o.name) aliasSet.add(o.name.toLowerCase()); }); await saveIngredientCost({ ...primary, aliases: [...aliasSet] }); for (const o of others) { await deleteIngredientCost(o.key); } // eslint-disable-line no-await-in-loop clearSel(); setMerging(false); }; const pad = isMobile ? '24px 18px 60px' : '40px 48px 80px'; return (
Settings

Settings.

{/* Preferences */} Preferences
setSettings({ unitSystem: v })} />
setSettings({ defaultPlanningServings: n })} />
{/* Ingredient prices */}
Ingredient prices
The prices the cost estimator uses · {(ingredientCosts || []).length} items
setEditing('__new__')} style={isMobile ? { width: '100%' } : {}}>Add ingredient
{editing === '__new__' && (
setEditing(null)} isMobile={isMobile} />
)}
setQ(e.target.value)} placeholder="search ingredients" style={{ border: 'none', background: 'transparent', outline: 'none', flex: 1, fontFamily: 'Bricolage Grotesque', fontSize: 14, color: 'var(--ink)' }} />
{selected.size > 0 && (
{selected.size} selected {selected.size < 2 ? 'pick one more to merge' : 'merge duplicates into one'}
setMerging(true)} style={selected.size < 2 ? { opacity: 0.5, pointerEvents: 'none' } : {}}>Merge…
)}
{list.length === 0 && (
No ingredients match “{q}”.
)} {list.map((it) => editing === it.key ? (
setEditing(null)} isMobile={isMobile} />
) : ( toggleSel(it.key)} onEdit={() => setEditing(it.key)} onDelete={() => deleteIngredientCost(it.key)} /> ))}
{merging && setMerging(false)} isMobile={isMobile} />} ); } Object.assign(window, { SettingsScreen });