// app/screen-recipe.jsx — read a recipe & decide. All actions live:
// start cooking · edit · favourite · add to shopping · plan this · share · rate · notes.
const overlayGhost = { background: 'rgba(12,12,14,0.55)', backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.18)', color: '#fff', boxShadow: 'none' };
function HeroDot() {
return ;
}
function heroTime(r) {
const info = (window.LCDomain && window.LCDomain.effortInfo) ? window.LCDomain.effortInfo(r) : null;
return info && info.known ? info.label : '—';
}
// ── interactive rating ──
function RatingStars({ recipe, size = 16 }) {
const { ratingFor, setRating } = useApp();
const v = ratingFor(recipe);
const [hover, setHover] = React.useState(0);
return (
setHover(0)}>
{[1, 2, 3, 4, 5].map((i) => {
const on = (hover || v) >= i;
return (
setHover(i)} onClick={() => setRating(recipe.id, i)}
style={{ background: 'transparent', border: 'none', cursor: 'pointer', padding: 0,
color: on ? 'var(--star)' : 'var(--paper-3)', display: 'inline-flex' }}>
);
})}
);
}
function portalToast(node) {
const host = (typeof document !== 'undefined') && document.getElementById('lc-screen');
return host ? ReactDOM.createPortal(node, host) : node;
}
function Toast({ msg, action, onAction, onClose, top = 'calc(var(--lc-mobile-top, 14px) + 8px)' }) {
React.useEffect(() => {
const t = setTimeout(onClose, 4200);
return () => clearTimeout(t);
}, [msg]);
// Portal into #lc-screen (the fixed device frame), NOT the scrolling content —
// so toasts always sit at the top of the visible screen regardless of scroll.
return portalToast(
{msg}
{action && (
{action} →
)}
);
}
function Popover({ children, onClose, align = 'left', up = false }) {
return (
<>
{children}
>
);
}
function PlanPopover({ recipe, onClose, onPlanned, up, plannedDay }) {
const { WEEK_DAYS, setPlanDay, state } = useApp();
const plannedKey = Object.keys(state.plan || {}).find(
(k) => state.plan[k] && !state.plan[k].skip && state.plan[k].recipeId === recipe.id);
return (
{plannedDay ? 'Planned for ' + plannedDay : 'Plan for…'}
{WEEK_DAYS.map((d) => {
const on = plannedKey === d.key;
return (
{
if (on) { setPlanDay(d.key, {}); onClose(); onPlanned(d.day, true); }
else { setPlanDay(d.key, { recipeId: recipe.id, why: 'you planned this', tag: 'your pick' });
onClose(); onPlanned(d.day); }
}} style={{ padding: '10px 6px', borderRadius: 10, cursor: 'pointer',
background: on ? 'rgba(240,168,80,0.16)' : 'rgba(255,255,255,0.04)',
border: on ? '1px solid rgba(240,168,80,0.5)' : '1px solid var(--hairline)',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
{d.day}
{d.date}
);
})}
{plannedDay && (
Tap {plannedDay.slice(0, 3)} again to remove · or pick another day to move it.
)}
);
}
function SharePopover({ recipe, onClose }) {
const [copied, setCopied] = React.useState(false);
const link = `luiscooks.app/r/${recipe.id}/shared/k3jf9`;
return (
Share this recipe
Read-only · attribution always shown
{
try { navigator.clipboard.writeText(link); } catch (e) {}
setCopied(true); setTimeout(() => setCopied(false), 1600);
}}>{copied ? 'Copied' : 'Copy'}
);
}
// ── method list ──
function MethodList({ recipe, mini }) {
return (
{recipe.directions.map((d, i) => (
{String(i + 1).padStart(2, '0')}
{d}
))}
);
}
function NotesCard({ recipe }) {
const { noteFor, setNote } = useApp();
const [editing, setEditing] = React.useState(false);
const note = noteFor(recipe);
return (
);
}
// The single Effort cell (replaces the old PREP / COOK columns). Effort =
// active, hands-on minutes. Three states: a value, an in-flight AI estimate,
// or — for recipes with no timing — an opt-in "Estimate" affordance.
function EffortCell({ recipe, compact }) {
const { estimateEffort } = useApp();
const info = window.LCDomain.effortInfo(recipe);
const valSize = compact ? 13.5 : 22;
if (info.status === 'estimating') {
return (
Estimating…
);
}
if (info.known) {
return (
{info.label}
);
}
// No timing at all → opt-in AI estimate (one credit, on demand).
return (
estimateEffort(recipe.id)} title="Estimate active time with AI"
style={{ display: 'inline-flex', alignItems: 'center', gap: 6, cursor: 'pointer',
padding: compact ? '5px 10px' : '7px 13px', borderRadius: 999,
border: '1px solid rgba(240,168,80,0.42)', background: 'rgba(240,168,80,0.10)',
color: 'var(--olive)', fontFamily: 'Bricolage Grotesque', fontWeight: 600,
fontSize: compact ? 11 : 12.5, whiteSpace: 'nowrap' }}>
Estimate
);
}
function MetaStrip({ recipe, cols, compact }) {
const tight = compact || cols <= 3;
const cells = [
{ l: 'rating', el: },
{ l: 'effort', el: },
{ l: 'serves', v: (recipe.servings || '').replace(/serves\s*/i, '') || '4' },
{ l: 'difficulty', v: recipe.difficulty },
];
// Mobile: four centred stats separated by hairlines, with a shared value
// baseline so stars, numbers and words line up cleanly.
if (compact) {
return (
{cells.map((m, i) => (
0 ? '1px solid var(--hairline)' : 'none' }}>
{m.l}
{m.el || {m.v} }
))}
);
}
return (
{cells.map((m, i) => (
= cols ? (tight ? 0 : 18) : 0,
borderRight: (i % cols !== cols - 1) ? '1px solid var(--hairline)' : 'none' }}>
{m.l}
{m.el ||
{m.v}
}
))}
);
}
// ── Action bar shared ──
function useRecipeActions(recipe) {
const { go, openRecipe, isFav, toggleFav, addRecipeToShopping, back, state, WEEK_DAYS } = useApp();
const [toast, setToast] = React.useState(null);
const [planOpen, setPlanOpen] = React.useState(false);
const [shareOpen, setShareOpen] = React.useState(false);
// Surface an enhancement failure as a toast (not an inline panel). Fires only
// on the enhancing→failed transition, so re-opening a failed recipe stays quiet.
const prevStatus = React.useRef(recipe.enhancementStatus);
React.useEffect(() => {
const s = recipe.enhancementStatus;
if (prevStatus.current === 'enhancing' && s === 'failed') {
setToast({ msg: recipe.enhancementError || 'Couldn’t enhance the photo — please try again.',
action: 'Try again', enhanceRetry: true });
}
prevStatus.current = s;
}, [recipe.enhancementStatus, recipe.enhancementError]);
const fav = isFav(recipe.id);
const startCooking = () => go('cook', { recipeId: recipe.id });
const edit = () => go('edit', { recipeId: recipe.id });
const addShopping = () => {
addRecipeToShopping(recipe.id);
};
// which day (if any) this recipe is planned for (plan is keyed by ISO date)
const DAY_FULL = { Mon: 'Monday', Tue: 'Tuesday', Wed: 'Wednesday', Thu: 'Thursday',
Fri: 'Friday', Sat: 'Saturday', Sun: 'Sunday' };
const plannedKey = Object.keys(state.plan || {}).find(
(k) => state.plan[k] && !state.plan[k].skip && state.plan[k].recipeId === recipe.id);
let plannedDay = null;
if (plannedKey) {
const wd = (WEEK_DAYS || []).find((d) => d.key === plannedKey);
if (wd) { plannedDay = DAY_FULL[wd.day]; }
else {
const [yy, mm, dd] = plannedKey.split('-').map(Number);
const dt = new Date(yy, (mm || 1) - 1, dd || 1);
const DOWF = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const MONF = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
plannedDay = `${DOWF[dt.getDay()]} ${dt.getDate()} ${MONF[dt.getMonth()]}`;
}
}
return { toast, setToast, planOpen, setPlanOpen, shareOpen, setShareOpen, fav, toggleFav,
startCooking, edit, addShopping, plannedDay };
}
// ── Desktop / tablet ──
// ─────────────────────────────────────────────────────────────
// Photo enhancement — the "Enhanced" badge becomes a control, and a
// generate panel takes the photo's place when a recipe has none.
// (Status + trigger come from the adapter; mock simulates, live calls the API.)
// ─────────────────────────────────────────────────────────────
(function injectEnhanceCSS() {
if (typeof document === 'undefined' || document.getElementById('lc-enhance-css')) return;
const st = document.createElement('style');
st.id = 'lc-enhance-css';
st.textContent = `
@keyframes lcSpin { to { transform: rotate(360deg); } }
@keyframes lcShimmer { 0% { transform: translateX(-120%); } 100% { transform: translateX(120%); } }
.lc-shimmer::after { content:''; position:absolute; inset:0;
background: linear-gradient(100deg, transparent 18%, rgba(240,168,80,0.18) 50%, transparent 82%);
transform: translateX(-120%); animation: lcShimmer 1.6s ease-in-out infinite; }
`;
document.head.appendChild(st);
})();
function Spinner({ size = 15, color = 'var(--olive)' }) {
return ;
}
// enhancementStatus is authoritative (the adapter sets it). Default to 'none'
// — a recipe with a real photo but no AI enhancement should offer *Enhance*,
// not read as already-enhanced. 'done' means an AI photo genuinely exists.
function enhanceStatusOf(recipe) {
return recipe.enhancementStatus || 'none';
}
const enhPill = { display: 'inline-flex', alignItems: 'center', gap: 8, height: 38,
padding: '0 14px', boxSizing: 'border-box',
borderRadius: 999, background: 'rgba(12,12,14,0.72)', backdropFilter: 'blur(10px)',
border: '1px solid rgba(255,255,255,0.14)', fontFamily: 'Bricolage Grotesque',
fontSize: 11, fontWeight: 600, letterSpacing: '0.06em', cursor: 'pointer', color: '#fff' };
const overlayBackBtn = { display: 'inline-flex', alignItems: 'center', gap: 8, height: 38,
padding: '0 16px 0 13px', borderRadius: 999, cursor: 'pointer', background: 'rgba(12,12,14,0.6)',
backdropFilter: 'blur(10px)', border: '1px solid rgba(255,255,255,0.16)', color: '#fff',
fontFamily: 'Bricolage Grotesque', fontSize: 13, fontWeight: 500 };
const heroKicker = { fontFamily: 'Bricolage Grotesque', fontSize: 11, fontWeight: 600,
letterSpacing: '0.28em', textTransform: 'uppercase', color: 'var(--olive)' };
const popHeading = { fontFamily: 'Spectral', fontStyle: 'italic', fontSize: 12,
color: 'var(--ink-3)', padding: '2px 6px 8px' };
// The over-photo control. Five states, driven by hasOriginalPhoto +
// enhancementStatus + enhancementMethod (see CONTRACT.md → Photo enhancement):
// has source, none → Enhance ▸ AI Upscale · AI Generate
// has source, upscaled → AI Upscaled ▸ Revert · Re-upscale · Switch to Generate
// has source, generated → AI Generated ▸ Revert · Re-generate · Switch to Upscale
// no source, none → AI Generate (single action)
// no source, generated → AI Generated ▸ Re-generate
function EnhanceControl({ recipe }) {
const { enhancePhoto, revertPhoto } = useApp();
const [open, setOpen] = React.useState(false);
const status = enhanceStatusOf(recipe);
const hasOriginal = !!recipe.hasOriginalPhoto;
const generated = recipe.enhancementMethod === 'generated';
const run = (method) => { setOpen(false); enhancePhoto(recipe.id, { method }); };
const revert = () => { setOpen(false); revertPhoto(recipe.id); };
// Enhancing — a busy pill (the hero also shows a shimmer sweep).
if (status === 'enhancing') {
return (
{hasOriginal ? 'Enhancing…' : 'Generating…'}
);
}
// Done — label reflects how the photo was made; menu options depend on
// whether a real source exists to revert to / switch from.
if (status === 'done') {
return (
setOpen((o) => !o)} style={{ ...enhPill, fontSize: 10,
letterSpacing: '0.18em', textTransform: 'uppercase' }}>
✦ {generated ? 'AI Generated' : 'AI Upscaled'}
{open && (
setOpen(false)} align="right">
{generated ? 'AI-generated photo' : 'AI-upscaled photo'}
{hasOriginal && } label="Revert to original"
onClick={revert} />}
{generated
? } label="Re-generate" onClick={() => run('generate')} />
: } label="Re-upscale" onClick={() => run('upscale')} />}
{hasOriginal && (generated
? } label="Switch to AI Upscale" onClick={() => run('upscale')} />
: } label="Switch to AI Generate" onClick={() => run('generate')} />)}
)}
);
}
// none / failed, NO source — a single Generate action (no Upscale, no Revert).
if (!hasOriginal) {
return (
run('generate')} style={enhPill}>
Generate Photo
);
}
// none / failed, HAS source — Enhance ▸ choose Upscale or Generate.
return (
setOpen((o) => !o)} style={enhPill}>
Enhance
{open && (
setOpen(false)} align="right">
Enhance this photo
} label="AI Upscale"
sub="Sharpen the real photo" onClick={() => run('upscale')} />
} label="AI Generate"
sub="Create a fresh image" onClick={() => run('generate')} />
)}
);
}
function PopRow({ icon, label, sub, onClick }) {
return (
(e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}>
{icon}
{label}
{sub && {sub} }
);
}
// Shared primary actions (Start cooking · Add to plan) — overlaid on a photo
// hero, or in-flow beneath the generate panel.
function HeroActions({ recipe, a, compact, overlay }) {
const ghost = overlay ? overlayGhost : undefined;
return (
Start cooking →
a.setPlanOpen((o) => !o)}
style={a.plannedDay
? { ...(ghost || {}), background: 'rgba(240,168,80,0.18)', borderColor: 'rgba(240,168,80,0.5)', color: 'var(--olive)' }
: ghost}>
{a.plannedDay
? <> {`Planned for ${a.plannedDay}`}>
: <> Add to plan>}
{a.planOpen &&
a.setPlanOpen(false)}
onPlanned={() => {}} />}
);
}
function RecipeWide({ recipe, compact }) {
const { back, photoFor, ratingFor, noteFor, go, enhancePhoto } = useApp();
const a = useRecipeActions(recipe);
const pad = compact ? '0 32px' : '0 48px';
const photo = photoFor(recipe.id);
return (
{/* HERO — same overlay layout whether or not there's a photo (placeholder fills in) */}
{photo
?
:
}
{recipe.enhancementStatus === 'enhancing' && (
)}
← Back
a.setShareOpen((o) => !o)}>
{a.shareOpen && a.setShareOpen(false)} />}
a.toggleFav(recipe.id)} active={a.fav}>
{(recipe.meals || []).join(' · ')}
{recipe.description && (
{recipe.description}
)}
{/* Secondary actions (popovers live here, in flow, never clipped) */}
{/* Detailed meta + cost */}
{/* Body */}
{a.toast &&
{ if (a.toast.enhanceRetry) enhancePhoto(recipe.id); else go(a.toast.go); a.setToast(null); }}
onClose={() => a.setToast(null)} />}
);
}
function SectionHead({ kicker, title, sub }) {
return (
{kicker}
{title}.
{sub &&
{sub}
}
);
}
function SidebarLabel({ children }) {
return {children} ;
}
function PlaceholderHero({ recipe, height }) {
return (
{recipe.placeholder || recipe.name.toLowerCase()}
);
}
// ── Mobile ──
function RecipeMobile({ recipe }) {
const { back, photoFor, go, enhancePhoto, revertPhoto } = useApp();
const a = useRecipeActions(recipe);
const [tab, setTab] = React.useState('Ingredients');
const [moreOpen, setMoreOpen] = React.useState(false);
const photo = photoFor(recipe.id);
// Photo action row for the ⋯ menu — mirrors the EnhanceControl states.
const pstatus = enhanceStatusOf(recipe);
const hasOriginal = !!recipe.hasOriginalPhoto;
const generated = recipe.enhancementMethod === 'generated';
let photoRows;
if (pstatus === 'enhancing') {
photoRows = } label={hasOriginal ? 'Enhancing…' : 'Generating…'} onClick={() => {}} />;
} else if (pstatus === 'done') {
photoRows = (
<>
{hasOriginal && } label="Revert to original"
onClick={() => { setMoreOpen(false); revertPhoto(recipe.id); }} />}
} label={generated ? 'Re-generate photo' : 'Re-upscale photo'}
onClick={() => { setMoreOpen(false); enhancePhoto(recipe.id, { method: generated ? 'generate' : 'upscale' }); }} />
>
);
} else {
photoRows = } label={hasOriginal ? 'Enhance photo' : 'Generate a photo'}
onClick={() => { setMoreOpen(false); enhancePhoto(recipe.id, hasOriginal ? {} : { method: 'generate' }); }} />;
}
return (
{/* Hero — same overlay layout whether or not there's a photo */}
{photo
?
:
}
{recipe.enhancementStatus === 'enhancing' && (
)}
{recipe.enhancementStatus === 'enhancing' && (
{hasOriginal ? 'Enhancing…' : 'Generating…'}
)}
a.toggleFav(recipe.id)} active={a.fav}>
setMoreOpen((o) => !o)}>
{moreOpen && (
setMoreOpen(false)} align="right">
Recipe options
{photoRows}
} label="Edit recipe"
onClick={() => { setMoreOpen(false); a.edit(); }} />
} label="Share recipe"
onClick={() => { setMoreOpen(false); a.setShareOpen(true); }} />
)}
{a.shareOpen &&
a.setShareOpen(false)} />}
{(recipe.meals || []).join(' · ')}
{/* Description (clamped) */}
{recipe.description && (
)}
Start cooking →
a.setPlanOpen((o) => !o)}
style={a.plannedDay ? { width: '100%', color: 'var(--olive)', borderColor: 'rgba(240,168,80,0.5)',
background: 'rgba(240,168,80,0.10)' } : { width: '100%' }}>
{a.plannedDay ? <> {`Planned for ${a.plannedDay.slice(0, 3)}`}> : <> Add to plan>}
{a.planOpen &&
a.setPlanOpen(false)}
onPlanned={() => {}} />}
{['Ingredients', 'Method', 'Notes'].map((t) => (
setTab(t)} style={{ flex: 1, fontFamily: 'Bricolage Grotesque',
fontSize: 13, fontWeight: 500, padding: '10px 12px', borderRadius: 999, textAlign: 'center',
cursor: 'pointer',
background: tab === t ? 'linear-gradient(180deg, rgba(255,255,255,0.10), rgba(255,255,255,0.02))' : 'transparent',
border: tab === t ? '1px solid rgba(255,255,255,0.14)' : '1px solid var(--hairline)',
color: tab === t ? 'var(--ink)' : 'var(--ink-3)' }}>{t}
))}
{tab === 'Ingredients' && }
{tab === 'Method' && }
{tab === 'Notes' && }
{a.toast &&
{ if (a.toast.enhanceRetry) enhancePhoto(recipe.id); else go(a.toast.go); a.setToast(null); }}
onClose={() => a.setToast(null)} />}
);
}
function HeartIcon({ filled, size = 17 }) {
return (
);
}
function RoundBtn({ children, onClick, active }) {
return (
{children}
);
}
function RecipeScreen() {
const { layout, getRecipe, state } = useApp();
const recipe = getRecipe(state.params.recipeId);
if (!recipe) return Recipe not found.
;
return (
{layout === 'mobile' ? : }
);
}
Object.assign(window, { RecipeScreen, Toast, RatingStars });