Move UI to BoardUI with slate + salmon theme; add PDF plan export

- Adopt BoardUI (Pro init): theme tokens, Inter, .dark mode, Button,
  SegmentedControl, Meter, Skeleton and ThemeToggle; remap the app's
  color aliases onto BoardUI tokens and swap lucide for Remix icons
- Retint BoardUI's neutral ramp to cool slate with a salmon accent;
  drop the dotted background; raise muted text contrast
- Simplify the landing page and remove the overlap explorer
- Planner: explicit "I have this" toggle on grocery lines, pantry panel
  above the menu with the staples list spelled out, sticky aisle
  header fix, and a "Download PDF" plan export (jsPDF, lazy-loaded)
- Recipes: replace the family cabbage soup with Cade's Cabbage Soup
  (adds Bone-In Ham Steak to the catalog); remove Ham, Bean & Cabbage Soup
- Remove unused BoardUI components, dependencies and exports

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Cayden Schwechten 2026-11-06 16:36:33 -06:00
parent b02c7a024b
commit df5dd3a70e
56 changed files with 4749 additions and 788 deletions

View file

@ -1,4 +1,4 @@
import { ArrowRight, BookHeart } from "lucide-react";
import { RiArrowRightLine as ArrowRight, RiHeart3Line as BookHeart } from "@remixicon/react";
import type { Metadata } from "next";
import Link from "next/link";
import { ViewTransition } from "react";
@ -26,7 +26,7 @@ export default function CaydensCornerPage() {
<div className="min-h-dvh">
<SiteHeader active="caydens-corner" />
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
<section className="paper-grain rounded-2xl border border-brand-border bg-brand-950 px-6 py-10 sm:px-10">
<section className="rounded-2xl border border-brand-border bg-brand-950 px-6 py-10 sm:px-10">
<p className="inline-flex items-center gap-2 font-mono text-xs font-semibold tracking-widest text-brand-300 uppercase">
<BookHeart className="size-4" strokeWidth={2} aria-hidden />
Cayden&apos;s Corner · Heirloom Cookbook
@ -69,7 +69,7 @@ export default function CaydensCornerPage() {
<li key={r.id}>
<Link
href={`/recipes/${r.id}`}
className="group flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
className="group flex h-full flex-col rounded-3xl border border-border-button-default bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
>
<ViewTransition name={`recipe-title-${r.id}`}>
<span className="font-serif text-lg leading-snug font-semibold text-ink group-hover:text-brand-300">

View file

@ -1,12 +1,13 @@
"use client";
import { ArrowRight, CalendarPlus, Check, X } from "lucide-react";
import { RiArrowRightLine as ArrowRight, RiCalendarCheckLine as CalendarPlus, RiCheckLine as Check, RiCloseLine as X } from "@remixicon/react";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
import { DAY_NAMES, mealLabel, slotKey, visibleMeals } from "@/app/lib/plan";
import { RECIPES_BY_ID } from "@/app/lib/recipes";
import { DEFAULT_STATE, sanitizeState } from "@/app/lib/sanitize";
import type { MealPlanState } from "@/app/types";
import { Button } from "@/components/base/buttons/button";
const STORAGE_KEY = "budget-meal-planner:v2";
@ -56,17 +57,15 @@ export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string;
return (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
<Button
leadingIcon={CalendarPlus}
onClick={() => {
setPlan(readPlan());
setOpen(true);
}}
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
>
<CalendarPlus className="size-4" strokeWidth={2} aria-hidden />
Add to meal plan
</button>
</Button>
{addedLabel && (
<span className="inline-flex flex-wrap items-center gap-2 rounded-lg border border-status-ok-line bg-status-ok-bg px-3 py-1.5 text-xs font-medium text-status-ok">
@ -84,7 +83,7 @@ export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string;
onClose={() => setOpen(false)}
onClick={(e) => e.target === dialogRef.current && setOpen(false)}
aria-labelledby="add-to-plan-title"
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-2xl"
className="m-0 mt-auto max-h-[85dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-lg sm:rounded-3xl"
>
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">

View file

@ -1,11 +1,12 @@
import { Meter, type MeterTone } from "@/components/base/meter/meter";
import { formatUsd } from "@/app/lib/units";
/** Herb sage under 85%, saffron amber up to 100%, chili red over budget. */
function budgetTone(spent: number, budget: number) {
/** Success under 85%, warning up to 100%, error over budget. */
function budgetTone(spent: number, budget: number): { tone: MeterTone; text: string; label: string } {
const ratio = budget > 0 ? spent / budget : 0;
if (ratio > 1) return { bar: "bg-status-danger", text: "text-status-danger", label: "Over budget" };
if (ratio > 0.85) return { bar: "bg-status-warn", text: "text-status-warn", label: "Close to budget" };
return { bar: "bg-status-ok", text: "text-status-ok", label: "Under budget" };
if (ratio > 1) return { tone: "error", text: "text-status-danger", label: "Over budget" };
if (ratio > 0.85) return { tone: "warning", text: "text-status-warn", label: "Close to budget" };
return { tone: "success", text: "text-status-ok", label: "Under budget" };
}
interface BudgetMeterProps {
@ -16,25 +17,22 @@ interface BudgetMeterProps {
export function BudgetMeter({ spent, budget, compact = false }: BudgetMeterProps) {
const tone = budgetTone(spent, budget);
const percent = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
return (
<div className={compact ? "w-full" : "w-full min-w-48"}>
<div className="flex items-baseline justify-between gap-2 text-xs">
<span className={`font-mono text-sm font-semibold tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
<span className="font-mono text-ink-muted tabular-nums">/ {formatUsd(budget)}</span>
<div className="flex items-baseline justify-between gap-2">
<span className={`text-body-medium tabular-nums ${tone.text}`}>{formatUsd(spent)}</span>
<span className="text-caption-1-medium text-text-secondary tabular-nums">/ {formatUsd(budget)}</span>
</div>
<div
className="mt-1.5 h-2 w-full overflow-hidden rounded-full border border-line-strong/40 bg-raised"
role="meter"
<Meter
className="mt-1.5"
aria-label="Budget used"
aria-valuemin={0}
aria-valuemax={budget}
aria-valuenow={Math.round(spent * 100) / 100}
aria-valuetext={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
>
<div className={`h-full rounded-full transition-[width] duration-300 ${tone.bar}`} style={{ width: `${percent}%` }} />
</div>
tone={tone.tone}
minValue={0}
maxValue={budget > 0 ? budget : 1}
value={Math.min(spent, budget > 0 ? budget : 0)}
valueLabel={`${formatUsd(spent)} of ${formatUsd(budget)}, ${tone.label.toLowerCase()}`}
/>
</div>
);
}

View file

@ -1,8 +1,9 @@
import { Check } from "lucide-react";
import { RiCheckLine } from "@remixicon/react";
import type { ReactNode } from "react";
import { cx } from "@/utils/cx";
// A pill-shaped on/off toggle button for filters (cuisines, styles, appliances…).
// Includes a visible Check icon when pressed so state never relies on color alone.
// A pill-shaped on/off filter toggle on BoardUI tokens.
// Shows a check when pressed so state never relies on color alone.
interface ChipProps {
pressed: boolean;
@ -14,24 +15,25 @@ interface ChipProps {
dashed?: boolean;
}
const SIZES = {
sm: "min-h-8 px-2.5 py-1 text-xs gap-1.5",
md: "min-h-9 px-3.5 py-1.5 text-sm gap-1.5",
};
export function Chip({ pressed, onClick, children, size = "md", dashed = false }: ChipProps) {
const look = pressed
? "border-selected-line bg-selected font-medium text-ink shadow-2xs"
: `border-line-strong bg-surface text-ink-muted hover:border-ink-muted hover:bg-raised hover:text-ink ${dashed ? "border-dashed" : ""}`;
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
className={`trim-cap inline-flex items-center rounded-full border transition-colors ${SIZES[size]} ${look}`}
className={cx(
"inline-flex cursor-pointer items-center gap-1.5 rounded-full border transition-colors duration-150 ease",
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
size === "sm" ? "min-h-8 px-2.5 py-1 text-caption-1-medium" : "min-h-9 px-3.5 py-1.5 text-body-medium",
pressed
? "border-accent-500 bg-button-ghost-background text-button-ghost-foreground"
: cx(
"border-border-button-default bg-background-primary-default text-text-secondary hover:border-border-button-hover hover:bg-background-secondary-hover hover:text-text-primary",
dashed && "border-dashed",
),
)}
>
{pressed && <Check className="size-3.5 shrink-0 text-brand-300" strokeWidth={2.5} aria-hidden />}
{pressed && <RiCheckLine className="size-3.5 shrink-0" aria-hidden />}
<span className="inline-flex items-center gap-1.5">{children}</span>
</button>
);

View file

@ -1,13 +1,15 @@
"use client";
import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react";
import { RiDownload2Line, RiArrowDownSLine as ChevronDown, RiResetLeftLine as RotateCcw, RiShuffleLine as Shuffle, RiEqualizerLine as SlidersHorizontal, RiMagicLine as Wand2 } from "@remixicon/react";
import { useState, type Dispatch } from "react";
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
import { buildPlanModel, renderPlanPdf } from "@/app/lib/exportPlan";
import type { PlanAction } from "@/app/lib/usePlanState";
import type { FillMode, MealPlanState } from "@/app/types";
import { Chip } from "./Chip";
import { ApplianceIcon, StyleIcon } from "./Icons";
import { RadioGroup } from "./RadioGroup";
import { Button } from "@/components/base/buttons/button";
interface ConfigPanelProps {
state: MealPlanState;
@ -36,6 +38,19 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
const [filtersOpen, setFiltersOpen] = useState(false);
const filledCount = activeRecipeIds(state).length;
const [isExporting, setIsExporting] = useState(false);
async function downloadPlan() {
setIsExporting(true);
try {
// Loaded on demand so jsPDF stays out of the planner's initial bundle.
const { jsPDF } = await import("jspdf");
renderPlanPdf(buildPlanModel(state), jsPDF).save("meal-plan.pdf");
} finally {
setIsExporting(false);
}
}
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
const activeFilterCount =
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
@ -51,7 +66,7 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
.join(" · ");
return (
<section aria-labelledby="config-heading" className="rounded-xl border border-line bg-surface p-4 sm:p-5">
<section aria-labelledby="config-heading" className="rounded-3xl border border-border-button-default bg-surface p-4 sm:p-5">
<h2 id="config-heading" className="sr-only">
Plan parameters
</h2>
@ -155,25 +170,13 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
{/* --- Action Toolbar + Progressive Disclosure Toggle --------------- */}
<div className="mt-4 flex flex-wrap items-center justify-between gap-2 border-t border-line pt-3.5">
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onGenerate("empty")}
disabled={isGenerating}
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 disabled:opacity-60"
>
<Wand2 className="size-4 shrink-0" strokeWidth={2} aria-hidden />
<Button leadingIcon={Wand2} onClick={() => onGenerate("empty")} disabled={isGenerating}>
{isGenerating ? "Planning…" : "Auto-fill empty"}
</button>
</Button>
<button
type="button"
onClick={() => onGenerate("all")}
disabled={isGenerating}
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3.5 py-2 text-sm font-medium text-ink transition-colors hover:bg-raised disabled:opacity-60"
>
<Shuffle className="size-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
<Button variant="secondary" leadingIcon={Shuffle} onClick={() => onGenerate("all")} disabled={isGenerating}>
Shuffle plan
</button>
</Button>
<button
type="button"
@ -184,6 +187,16 @@ export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
Clear
</button>
<Button
variant="secondary"
leadingIcon={RiDownload2Line}
onClick={downloadPlan}
disabled={isGenerating || isExporting || filledCount === 0}
title="Save the week as a PDF, with the full recipe for every meal, organized by day"
>
{isExporting ? "Preparing PDF…" : "Download PDF"}
</Button>
</div>
<button

View file

@ -1,9 +1,10 @@
"use client";
import { Check, Copy, Repeat2, Square, SquareCheckBig, X } from "lucide-react";
import { RiCheckLine as Check, RiFileCopyLine as Copy, RiRepeat2Line as Repeat2, RiCloseLine as X } from "@remixicon/react";
import { useEffect, useRef, useState } from "react";
import { formatAmount, formatUsd } from "@/app/lib/units";
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
import { cx } from "@/utils/cx";
import { BudgetMeter } from "./BudgetMeter";
interface PanelProps {
@ -35,7 +36,7 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
return (
<div>
{/* --- Tactile Market Receipt Header -------------------------------- */}
<div className="paper-grain rounded-xl border border-line bg-canvas p-4">
<div className="rounded-3xl border border-border-button-default bg-canvas p-4">
<BudgetMeter spent={summary.checkoutTotal} budget={budget} />
<dl className="receipt-rule mt-4 grid grid-cols-2 gap-3 pt-3.5 text-xs">
@ -61,7 +62,7 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
<div className="mt-5 flex items-center justify-between">
<div>
<h3 className="font-serif text-base font-semibold">Shopping list</h3>
<p className="text-xs text-ink-faint">Tap items you already have on hand</p>
<p className="text-xs text-ink-faint">Tap &ldquo;I have this&rdquo; on anything in your kitchen and it drops off the bill.</p>
</div>
{summary.lines.length > 0 && (
<button
@ -102,48 +103,46 @@ export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggl
}
function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) {
const { item } = line;
const { item, onHand } = line;
return (
<li>
<button
type="button"
onClick={() => onToggleOnHand(item.id, !line.onHand)}
aria-pressed={line.onHand}
aria-label={line.onHand ? `Mark ${item.name} as needed at the store` : `Mark ${item.name} as already on hand`}
className={`flex min-h-12 w-full items-start justify-between gap-3 py-2.5 text-left text-sm transition-colors hover:bg-raised/50 ${
line.onHand ? "text-ink-faint" : "text-ink"
}`}
>
<span className="flex min-w-0 items-start gap-2.5">
<span className="mt-0.5 shrink-0 text-ink-muted">
{line.onHand ? (
<SquareCheckBig className="size-4 text-status-ok" strokeWidth={2} aria-hidden />
) : (
<Square className="size-4 text-line-strong" strokeWidth={1.75} aria-hidden />
)}
</span>
<span className="min-w-0">
<span className={`block leading-snug ${line.onHand ? "line-through" : "font-medium"}`}>
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
{item.name}
</span>
<span className="mt-0.5 block text-xs text-ink-muted">
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
{line.onHand && " · on hand"}
</span>
{line.usedBy.length > 1 && (
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
<Repeat2 className="size-3 shrink-0" aria-hidden />
Used in {line.usedBy.length} meals
</span>
)}
</span>
<li className="flex min-h-12 items-start justify-between gap-3 py-2.5 text-sm">
<span className="min-w-0">
<span className={cx("block leading-snug", onHand ? "text-ink-faint line-through" : "font-medium text-ink")}>
{line.packages > 1 && <span className="font-mono tabular-nums">{line.packages} × </span>}
{item.name}
</span>
<span className="mt-0.5 block text-xs text-ink-muted">
{item.packageLabel} · need {formatAmount(line.neededAmount, item.baseUnit)}
</span>
{line.usedBy.length > 1 && (
<span className="mt-0.5 inline-flex items-center gap-1 text-xs text-ink-faint" title={line.usedBy.join(", ")}>
<Repeat2 className="size-3 shrink-0" aria-hidden />
Used in {line.usedBy.length} meals
</span>
)}
</span>
<span className="shrink-0 text-right font-mono text-sm font-medium tabular-nums">
{line.onHand ? "$0.00" : formatUsd(line.checkoutCost)}
<span className="flex shrink-0 flex-col items-end gap-1.5">
<span className={cx("font-mono text-sm font-medium tabular-nums", onHand ? "text-ink-faint" : "text-ink")}>
{onHand ? "$0.00" : formatUsd(line.checkoutCost)}
</span>
</button>
<button
type="button"
onClick={() => onToggleOnHand(item.id, !onHand)}
aria-pressed={onHand}
aria-label={`I have ${item.name}`}
title={onHand ? "Counted as $0. Tap to add it back to the bill." : "Already have it? Tap to take it off the bill."}
className={cx(
"inline-flex min-h-7 cursor-pointer items-center gap-1 rounded-full border px-2.5 text-caption-1-medium whitespace-nowrap transition-colors duration-150",
"outline-none focus-visible:ring-2 focus-visible:ring-border-focus-ring",
onHand
? "border-status-ok-line bg-status-ok-bg text-status-ok"
: "border-border-button-default bg-background-primary-default text-ink-muted hover:border-border-button-hover hover:text-ink",
)}
>
{onHand && <Check className="size-3.5 shrink-0" aria-hidden />}I have this
</button>
</span>
</li>
);
}
@ -198,7 +197,7 @@ export function GroceryListDrawer({ open, onClose, ...panelProps }: DrawerProps)
onClose={onClose}
onClick={(e) => e.target === dialogRef.current && onClose()}
aria-labelledby="grocery-drawer-title"
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
className="sheet-dialog m-0 mt-auto max-h-[88dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border-t border-line bg-surface p-0 shadow-2xl lg:hidden"
>
<div className="p-4 pb-8 sm:px-6">
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">

View file

@ -1,23 +1,10 @@
import type { LucideProps } from "lucide-react";
import {
BookHeart,
CookingPot,
CupSoda,
Fish,
Flame,
Heater,
Leaf,
PackageCheck,
Salad,
Soup,
Timer,
Utensils,
Wind,
Zap,
} from "lucide-react";
import { RiHeart3Line as BookHeart, RiRestaurant2Line as CookingPot, RiCupLine as CupSoda, RiSailboatLine as Fish, RiFireLine as Flame, RiTempHotLine as Heater, RiLeafLine as Leaf, RiArchive2Line as PackageCheck, RiSeedlingLine as Salad, RiBowlLine as Soup, RiTimerLine as Timer, RiRestaurantLine as Utensils, RiWindyLine as Wind, RiFlashlightLine as Zap } from "@remixicon/react";
import type { ComponentProps } from "react";
import type { Appliance, CookingStyle } from "@/app/types";
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & LucideProps) {
type RemixiconProps = ComponentProps<typeof Flame>;
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & RemixiconProps) {
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
switch (id) {
case "stovetop":
@ -37,7 +24,7 @@ export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appl
}
}
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & LucideProps) {
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & RemixiconProps) {
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
switch (id) {
case "caydens_corner":

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronDown, Search, X } from "lucide-react";
import { RiArrowDownSLine as ChevronDown, RiSearchLine as Search, RiCloseLine as X } from "@remixicon/react";
import { useMemo, useState, type Dispatch } from "react";
import { CATALOG, CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
import { RECIPES } from "@/app/lib/recipes";
@ -8,6 +8,9 @@ import type { PlanAction } from "@/app/lib/usePlanState";
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
import { RadioGroup, type RadioOption } from "./RadioGroup";
// Shown beside the staples toggle so people know exactly what it marks as $0.
const STAPLE_NAMES = PANTRY_STAPLE_IDS.map((id) => CATALOG_BY_ID.get(id)?.name).filter(Boolean).join(", ");
interface IngredientPanelProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
@ -63,7 +66,7 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
const staplesAllOnHand = PANTRY_STAPLE_IDS.every((id) => state.onHandItems.includes(id));
return (
<section aria-labelledby="ingredients-heading" className="rounded-xl border border-line bg-surface">
<section aria-labelledby="ingredients-heading" className="rounded-3xl border border-border-button-default bg-surface">
<details className="group">
<summary className="flex cursor-pointer list-none items-center justify-between gap-4 p-4 sm:px-5 [&::-webkit-details-marker]:hidden">
<div>
@ -115,10 +118,11 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
<RadioGroup name="Filter ingredients" options={FILTER_OPTIONS} value={filter} onChange={setFilter} size="xs" />
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1 text-xs sm:text-sm">
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs sm:text-sm">
<button
type="button"
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
aria-describedby="pantry-staples-list"
className="min-h-8 font-medium text-brand-300 hover:underline"
>
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
@ -132,6 +136,9 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
Reset all to &ldquo;Use&rdquo;
</button>
)}
<p id="pantry-staples-list" className="basis-full text-xs text-ink-muted">
Staples: {STAPLE_NAMES}.
</p>
</div>
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
@ -139,7 +146,8 @@ export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
{groups.map(([aisle, items]) => (
<section key={aisle}>
<div className="sticky top-0 z-10 flex items-center justify-between border-b border-line bg-surface py-1.5">
{/* z-20: BoardUI segment buttons are z-10, so the sticky header must sit above them while rows scroll under it */}
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-line bg-surface py-1.5">
<h3 className="font-mono text-xs font-semibold tracking-wider text-ink-muted uppercase">{aisle}</h3>
<AisleBulkToggle itemIds={items.map((i) => i.id)} avoidedItems={state.avoidedItems} onSet={setPreference} />
</div>

View file

@ -1,6 +1,6 @@
"use client";
import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-react";
import { RiAlertLine as AlertTriangle, RiArrowLeftRightLine as ArrowLeftRight, RiAddLine as Plus, RiCloseLine as X } from "@remixicon/react";
import { useState, type Dispatch } from "react";
import { APPLIANCES, DAY_NAMES, activeSlots, describeBlockers, isCookable, mealLabel, recipeBlockers, slotKey } from "@/app/lib/plan";
import { RECIPES_BY_ID } from "@/app/lib/recipes";
@ -47,7 +47,7 @@ export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, o
const hasAnyMeal = slots.some((s) => s.recipeId !== null);
return (
<article key={day} className="flex flex-col overflow-hidden rounded-xl border border-line bg-surface">
<article key={day} className="flex flex-col overflow-hidden rounded-3xl border border-border-button-default bg-surface">
{/* Architectural day header */}
<div className="flex items-baseline justify-between border-b border-line bg-raised/65 px-4 py-2.5">
<h3 className="font-serif text-base font-semibold tracking-tight text-ink">{DAY_NAMES[day]}</h3>

View file

@ -1,6 +1,6 @@
"use client";
import { ChevronDown, ChevronUp, Repeat2, Search, SlidersHorizontal, X } from "lucide-react";
import { RiArrowDownSLine as ChevronDown, RiArrowUpSLine as ChevronUp, RiRepeat2Line as Repeat2, RiSearchLine as Search, RiEqualizerLine as SlidersHorizontal, RiCloseLine as X } from "@remixicon/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Cart } from "@/app/lib/grocery";
import { CUISINES, DAY_NAMES, STYLES, activeRecipeIds, describeBlockers, mealKindFor, mealLabel, recipeBlockers, toggle } from "@/app/lib/plan";
@ -10,6 +10,7 @@ import type { CookingStyle, Cuisine, MealPlanState, Recipe } from "@/app/types";
import { Chip } from "./Chip";
import { StyleIcon } from "./Icons";
import { RecipeDetail } from "./RecipeDetail";
import { Button } from "@/components/base/buttons/button";
interface MealSelectorModalProps {
slotKey: string | null;
@ -37,7 +38,7 @@ export function MealSelectorModal({ slotKey, state, onPick, onClose }: MealSelec
onClose={onClose}
onClick={(e) => e.target === dialogRef.current && onClose()}
aria-labelledby="selector-title"
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-2xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-2xl"
className="m-0 mt-auto max-h-[90dvh] w-full max-w-none overflow-hidden rounded-t-3xl border border-line bg-surface p-0 shadow-2xl sm:m-auto sm:max-w-2xl sm:rounded-3xl"
>
{slotKey && <SelectorContent key={slotKey} slotKey={slotKey} state={state} onPick={onPick} onClose={onClose} />}
</dialog>
@ -278,13 +279,9 @@ function SelectorContent({
<div id={`recipe-${recipe.id}`} className="border-t border-line bg-canvas/50 px-3.5 py-4">
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} compact />
{!disabled && (
<button
type="button"
onClick={() => onPick(recipe.id)}
className="trim-cap mt-4 min-h-10 w-full rounded-lg bg-brand-600 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
>
<Button className="mt-4 w-full" onClick={() => onPick(recipe.id)}>
Add to {DAY_NAMES[dayIndex]} {mealLabel(mealNumber).toLowerCase()}
</button>
</Button>
)}
</div>
)}

View file

@ -1,149 +0,0 @@
"use client";
import { Check, Plus, Repeat2 } from "lucide-react";
import { useState } from "react";
export interface OverlapDish {
id: string;
title: string;
cuisine: string;
standaloneCheckout: number;
}
export interface OverlapComboSnapshot {
checkoutTotal: number;
usedTotal: number;
carryoverValue: number;
sharedNames: string[];
packageCount: number;
}
interface OverlapExplorerProps {
dishes: OverlapDish[];
/** Keyed by sorted comma-joined dish IDs; precomputed on the server so zero catalog data ships to the landing bundle. */
combos: Record<string, OverlapComboSnapshot>;
}
const fmt = (n: number) => `$${n.toFixed(2)}`;
export function OverlapExplorer({ dishes, combos }: OverlapExplorerProps) {
const [selected, setSelected] = useState<string[]>(() => dishes.slice(0, 2).map((d) => d.id));
function toggleDish(id: string) {
setSelected((prev) => {
if (prev.includes(id)) {
return prev.length > 1 ? prev.filter((x) => x !== id) : prev; // keep at least 1 selected
}
return [...prev, id];
});
}
const comboKey = [...selected].sort().join(",");
const current = combos[comboKey];
const naiveSum = dishes.filter((d) => selected.includes(d.id)).reduce((acc, d) => acc + d.standaloneCheckout, 0);
const saved = Math.max(0, naiveSum - (current?.checkoutTotal ?? 0));
return (
<div className="grid gap-6 rounded-xl border border-line-strong bg-surface p-5 sm:p-7 lg:grid-cols-[1.15fr_1fr]">
<div>
<p className="font-mono text-xs tracking-wider text-brand-300 uppercase">Interactive checkout math · 2 people</p>
<h3 className="mt-1 font-serif text-2xl font-semibold tracking-tight text-balance">
Toggle dishes to watch shared packages drop the bill
</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
Grocery stores sell whole bags of onions, rice, and spices—not single tablespoons. Selecting meals that reuse open
packages slashes what each extra dinner adds at the register.
</p>
<ul className="mt-5 space-y-2" aria-label="Sample dishes">
{dishes.map((dish) => {
const active = selected.includes(dish.id);
return (
<li key={dish.id}>
<button
type="button"
aria-pressed={active}
onClick={() => toggleDish(dish.id)}
className={`flex min-h-12 w-full items-center justify-between gap-3 rounded-lg border px-3.5 py-2.5 text-left transition-colors ${
active
? "border-selected-line bg-selected text-ink"
: "border-line bg-canvas text-ink-muted hover:border-line-strong hover:text-ink"
}`}
>
<span className="flex items-center gap-3 min-w-0">
<span
className={`grid size-5 shrink-0 place-items-center rounded border ${
active ? "border-brand-600 bg-brand-600 text-on-accent" : "border-line-strong bg-surface"
}`}
>
{active ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : <Plus className="size-3 text-ink-faint" aria-hidden />}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-ink">{dish.title}</span>
<span className="block text-xs text-ink-faint">{dish.cuisine}</span>
</span>
</span>
<span className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">
{fmt(dish.standaloneCheckout)} alone
</span>
</button>
</li>
);
})}
</ul>
</div>
{/* Simulated paper receipt */}
<div className="paper-grain flex flex-col justify-between rounded-lg border border-line bg-canvas p-5 font-mono text-sm">
<div>
<div className="flex items-center justify-between border-b border-line pb-3 text-xs text-ink-faint uppercase">
<span>Checkout Receipt</span>
<span>{selected.length} {selected.length === 1 ? "meal" : "meals"}</span>
</div>
<dl className="mt-4 space-y-2.5 text-xs sm:text-sm">
<div className="flex justify-between text-ink-muted">
<dt>Buying each meal separately</dt>
<dd className="line-through tabular-nums">{fmt(naiveSum)}</dd>
</div>
{saved > 0.01 && (
<div className="flex justify-between font-semibold text-status-ok">
<dt>Shared-package savings</dt>
<dd className="tabular-nums">−{fmt(saved)}</dd>
</div>
)}
<div className="receipt-rule flex items-baseline justify-between pt-3 text-base font-bold text-ink">
<dt className="font-sans text-sm font-semibold">Combined register total</dt>
<dd className="text-lg tabular-nums">{fmt(current?.checkoutTotal ?? 0)}</dd>
</div>
</dl>
<dl className="mt-4 grid grid-cols-2 gap-3 rounded-md border border-line bg-surface p-3 font-sans text-xs">
<div>
<dt className="text-ink-faint">Food eaten</dt>
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.usedTotal ?? 0)}</dd>
</div>
<div>
<dt className="text-ink-faint">Kept in pantry</dt>
<dd className="mt-0.5 font-mono text-sm font-semibold text-ink tabular-nums">{fmt(current?.carryoverValue ?? 0)}</dd>
</div>
</dl>
</div>
<div className="mt-4 border-t border-line pt-3 font-sans text-xs text-ink-muted">
{current && current.sharedNames.length > 0 ? (
<p className="flex items-start gap-1.5">
<Repeat2 className="mt-0.5 size-3.5 shrink-0 text-status-ok" strokeWidth={2} aria-hidden />
<span>
<strong className="font-semibold text-ink">Reused across meals:</strong> {current.sharedNames.slice(0, 5).join(", ")}
{current.sharedNames.length > 5 ? ` +${current.sharedNames.length - 5} more` : ""}
</span>
</p>
) : (
<p>Select a second dish to see which store packages overlap in your cart.</p>
)}
</div>
</div>
</div>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
import { RiErrorWarningLine as AlertCircle, RiResetLeftLine as RotateCcw, RiShoppingBag3Line as ShoppingBag, RiCloseLine as X } from "@remixicon/react";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { generateMealPlanAction } from "@/app/actions";
import { Cart, buildGroceryList } from "@/app/lib/grocery";
@ -17,6 +17,7 @@ import { MealGrid } from "./MealGrid";
import { MealSelectorModal } from "./MealSelectorModal";
import { Navbar } from "./Navbar";
import { RecipeDialog } from "./RecipeDialog";
import { Button } from "@/components/base/buttons/button";
interface UndoSnapshot {
message: string;
@ -99,7 +100,7 @@ export default function PlannerDashboard() {
<main className="mx-auto max-w-7xl px-4 pt-6 pb-28 sm:px-6 lg:pb-12">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Left: compact control bar -> immediate MealGrid -> Pantry below */}
{/* Left: control bar -> pantry & exclusions -> MealGrid */}
<div className="space-y-6 lg:col-span-2">
<ConfigPanel
state={state}
@ -133,6 +134,8 @@ export default function PlannerDashboard() {
</div>
)}
<IngredientPanel state={state} dispatch={dispatch} />
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
<MealGrid
state={state}
@ -143,13 +146,11 @@ export default function PlannerDashboard() {
onRemoveSlot={handleRemoveSlot}
/>
</div>
<IngredientPanel state={state} dispatch={dispatch} />
</div>
{/* Right: persistent Market Receipt summary (desktop) */}
<aside className="hidden lg:block">
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-xl border border-line bg-surface p-5">
<div className="sticky top-20 max-h-[calc(100dvh-6rem)] overflow-y-auto rounded-3xl border border-border-button-default bg-surface p-5">
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
<GrocerySummaryPanel
summary={summary}
@ -170,14 +171,9 @@ export default function PlannerDashboard() {
className="fixed bottom-20 left-1/2 z-40 flex -translate-x-1/2 items-center gap-3 rounded-xl border border-line-strong bg-surface px-4 py-2.5 text-sm shadow-lg lg:bottom-6"
>
<span className="text-ink">{undo.message}</span>
<button
type="button"
onClick={handleUndo}
className="trim-cap inline-flex min-h-8 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-1 text-xs font-semibold text-on-accent hover:bg-brand-500"
>
<RotateCcw className="size-3.5" aria-hidden />
<Button size="small" leadingIcon={RotateCcw} onClick={handleUndo}>
Undo
</button>
</Button>
<button
type="button"
onClick={() => setUndo(null)}
@ -195,14 +191,9 @@ export default function PlannerDashboard() {
<div className="flex-1">
<BudgetMeter spent={summary.checkoutTotal} budget={state.targetBudget} compact />
</div>
<button
type="button"
onClick={() => setGroceriesOpen(true)}
className="trim-cap inline-flex min-h-10 shrink-0 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent shadow-2xs hover:bg-brand-500"
>
<ShoppingBag className="size-4" strokeWidth={2} aria-hidden />
<Button className="shrink-0" leadingIcon={ShoppingBag} onClick={() => setGroceriesOpen(true)}>
Receipt ({summary.lines.length})
</button>
</Button>
</div>
<p className="sr-only" aria-live="polite">
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars

View file

@ -1,6 +1,7 @@
"use client";
import dynamic from "next/dynamic";
import { Skeleton } from "@/components/base/skeleton/skeleton";
const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), {
ssr: false,
@ -13,18 +14,18 @@ export function PlannerLoader() {
function DashboardSkeleton() {
return (
<div className="animate-pulse" aria-busy="true" aria-label="Loading planner">
<div aria-busy="true" aria-label="Loading planner">
<div className="h-15 border-b border-line bg-surface" />
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-6 px-4 pt-6 sm:px-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
<div className="h-36 rounded-xl bg-raised" />
<Skeleton className="h-36 rounded-3xl" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{Array.from({ length: 4 }, (_, i) => (
<div key={i} className="h-44 rounded-xl bg-raised" />
<Skeleton key={i} className="h-44 rounded-3xl" />
))}
</div>
</div>
<div className="hidden h-[32rem] rounded-xl bg-raised lg:block" />
<Skeleton className="hidden h-[32rem] rounded-3xl lg:block" />
</div>
</div>
);

View file

@ -1,10 +1,13 @@
"use client";
import { useRef, type KeyboardEvent, type ReactNode } from "react";
import type { ReactNode } from "react";
import { SegmentedControl, SegmentedControlItem } from "@/components/base/segmented-control/segmented-control";
import { cx } from "@/utils/cx";
export interface RadioOption<T extends string | number> {
value: T;
label: ReactNode;
/** Extra classes on the selected segment (e.g. a status tint). */
activeClass?: string;
}
@ -17,79 +20,32 @@ interface RadioGroupProps<T extends string | number> {
fullWidth?: boolean;
}
/**
* Accessible segmented control implementing WAI-ARIA `radiogroup` roving tabIndex
* and arrow/Home/End keyboard navigation (1 Tab stop per group instead of N).
*/
export function RadioGroup<T extends string | number>({
name,
options,
value,
onChange,
size = "sm",
fullWidth = false,
}: RadioGroupProps<T>) {
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
const currentIndex = Math.max(0, options.findIndex((o) => o.value === value));
let nextIndex = -1;
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
nextIndex = (currentIndex + 1) % options.length;
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
nextIndex = (currentIndex - 1 + options.length) % options.length;
} else if (e.key === "Home") {
nextIndex = 0;
} else if (e.key === "End") {
nextIndex = options.length - 1;
}
if (nextIndex !== -1) {
e.preventDefault();
onChange(options[nextIndex].value);
buttonsRef.current[nextIndex]?.focus();
}
}
const pad =
size === "xs"
? fullWidth
? "min-h-8 flex-1 min-w-0 px-1.5 py-1 text-xs"
: "min-h-8 px-2.5 py-1 text-xs"
: fullWidth
? "min-h-9 flex-1 min-w-0 px-2 py-1.5 text-sm"
: "min-h-9 min-w-9 px-3 py-1.5 text-sm";
/** Single-select segmented control: BoardUI's SegmentedControl (React Aria handles roving focus + arrow keys). */
export function RadioGroup<T extends string | number>({ name, options, value, onChange, size = "sm", fullWidth = false }: RadioGroupProps<T>) {
return (
<div
role="radiogroup"
<SegmentedControl
aria-label={name}
onKeyDown={handleKeyDown}
className={`${fullWidth ? "flex w-full" : "inline-flex shrink-0"} max-w-full rounded-lg border border-line-strong bg-raised p-0.5`}
selectedKeys={[value]}
onSelectionChange={(keys) => {
const next = options.find((o) => keys.has(o.value));
if (next) onChange(next.value);
}}
className={cx("max-w-full", fullWidth ? "flex w-full" : "shrink-0")}
>
{options.map((option, i) => {
const checked = option.value === value;
const activeStyle = option.activeClass ?? "bg-surface text-ink border border-selected-line/60 shadow-2xs";
return (
<button
key={String(option.value)}
ref={(el) => {
buttonsRef.current[i] = el;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
onClick={() => onChange(option.value)}
className={`trim-cap inline-flex items-center justify-center rounded-md font-medium tabular-nums transition-colors ${pad} ${
checked ? activeStyle : "border border-transparent text-ink-muted hover:text-ink"
}`}
>
{option.label}
</button>
);
})}
</div>
{options.map((option) => (
<SegmentedControlItem
key={String(option.value)}
id={option.value}
className={cx(
"tabular-nums",
size === "xs" && "min-h-7 px-2 text-caption-1-medium",
fullWidth && "min-w-0 flex-1",
option.value === value && option.activeClass,
)}
>
{option.label}
</SegmentedControlItem>
))}
</SegmentedControl>
);
}

View file

@ -1,6 +1,6 @@
"use client";
import { BookHeart, Search, SlidersHorizontal } from "lucide-react";
import { RiHeart3Line as BookHeart, RiSearchLine as Search, RiEqualizerLine as SlidersHorizontal } from "@remixicon/react";
import Link from "next/link";
import { ViewTransition, useMemo, useState, type ReactNode } from "react";
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES, toggle } from "@/app/lib/plan";
@ -91,7 +91,7 @@ export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
</button>
</div>
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-xl border border-line bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
<div className={`space-y-5 ${mobileFiltersOpen ? "block rounded-3xl border border-border-button-default bg-surface p-4" : "hidden"} lg:block lg:border-0 lg:bg-transparent lg:p-0`}>
<FilterGroup label="Course">
{(
[
@ -174,7 +174,7 @@ export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
<li key={r.id}>
<Link
href={`/recipes/${r.id}`}
className="group flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
className="group flex h-full flex-col rounded-3xl border border-border-button-default bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
>
<span className="flex items-center gap-1.5 font-mono text-[11px] tracking-wide text-ink-faint uppercase">
{r.styles.includes("caydens_corner") && (

View file

@ -1,6 +1,6 @@
"use client";
import { Check, Lightbulb } from "lucide-react";
import { RiCheckLine as Check, RiLightbulbLine as Lightbulb } from "@remixicon/react";
import { useState } from "react";
import { ingredientLines } from "@/app/lib/display";
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";

View file

@ -1,12 +1,13 @@
"use client";
import { AlertTriangle, ArrowRight, ArrowRightLeft, Trash2, X } from "lucide-react";
import { RiAlertLine as AlertTriangle, RiArrowRightLine as ArrowRight, RiArrowLeftRightLine as ArrowRightLeft, RiDeleteBinLine as Trash2, RiCloseLine as X } from "@remixicon/react";
import Link from "next/link";
import { useEffect, useRef } from "react";
import { DAY_NAMES, describeBlockers, isCookable, mealLabel, recipeBlockers } from "@/app/lib/plan";
import { RECIPES_BY_ID } from "@/app/lib/recipes";
import type { MealPlanState } from "@/app/types";
import { RecipeDetail, RecipeMeta } from "./RecipeDetail";
import { Button } from "@/components/base/buttons/button";
interface RecipeDialogProps {
slotKey: string | null;
@ -39,7 +40,7 @@ export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: Reci
onClose={onClose}
onClick={(e) => e.target === dialogRef.current && onClose()}
aria-labelledby="recipe-dialog-title"
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-2xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-2xl"
className="m-0 mt-auto max-h-[92dvh] w-full max-w-none overflow-y-auto rounded-t-3xl border border-line bg-surface p-0 text-ink shadow-2xl sm:m-auto sm:max-w-3xl sm:rounded-3xl"
>
{open && recipe && slotKey && (
<div className="p-5 sm:p-7">
@ -79,14 +80,9 @@ export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: Reci
</div>
<div className="mt-8 flex flex-wrap items-center gap-2.5 border-t border-line pt-4">
<button
type="button"
onClick={() => onSwap(slotKey)}
className="trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-on-accent transition-colors hover:bg-brand-500"
>
<ArrowRightLeft className="size-4" aria-hidden />
<Button leadingIcon={ArrowRightLeft} onClick={() => onSwap(slotKey)}>
Replace dish
</button>
</Button>
<button
type="button"
onClick={() => onRemove(slotKey, recipe.title)}

View file

@ -1,6 +1,6 @@
"use client";
import { Flame, Minus, Plus } from "lucide-react";
import { RiFireLine as Flame, RiSubtractLine as Minus, RiAddLine as Plus } from "@remixicon/react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { Recipe } from "@/app/types";
import { RecipeDetail } from "./RecipeDetail";

View file

@ -1,6 +1,6 @@
"use client";
import { Pause, Play, Repeat2, ShoppingBag } from "lucide-react";
import { RiPauseLine as Pause, RiPlayLine as Play, RiRepeat2Line as Repeat2, RiShoppingBag3Line as ShoppingBag } from "@remixicon/react";
import { useEffect, useState } from "react";
export interface PreviewSet {

View file

@ -1,9 +1,11 @@
import { BookHeart, BookOpen, CalendarRange, UtensilsCrossed } from "lucide-react";
import { RiHeart3Line as BookHeart, RiBookOpenLine as BookOpen, RiCalendarLine as CalendarRange, RiRestaurantLine as UtensilsCrossed } from "@remixicon/react";
import Link from "next/link";
import type { ReactNode } from "react";
import { ThemeToggle } from "./ThemeToggle";
import { buttonStyles } from "@/components/base/buttons/button";
import { ThemeToggle } from "@/components/application/theme/theme-toggle";
import { cx } from "@/utils/cx";
export type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) {
return (
@ -58,12 +60,15 @@ interface SiteHeaderProps {
export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderProps) {
const linkClass = (isCurrent: boolean) =>
`trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors sm:px-3 ${
isCurrent ? "bg-selected text-ink" : "text-ink-muted hover:bg-raised hover:text-ink"
}`;
cx(
"inline-flex h-9 items-center gap-1.5 rounded-2lg px-2.5 text-body-medium transition-colors duration-150 sm:px-3",
isCurrent
? "bg-background-secondary-default text-text-primary"
: "text-text-secondary hover:bg-background-secondary-hover hover:text-text-primary",
);
return (
<header className="sticky top-0 z-30 border-b border-line bg-surface/90 backdrop-blur-md">
<header className="sticky top-0 z-30 border-b border-separator-border bg-background-primary-default/90 backdrop-blur-md">
<div className="mx-auto flex max-w-7xl items-center gap-2 px-4 py-2.5 sm:gap-4 sm:px-6">
<Logo subtitle={subtitle} />
@ -91,9 +96,9 @@ export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderP
{active !== "plan" && (
<Link
href="/plan"
className="trim-cap ml-1 inline-flex min-h-10 items-center gap-1.5 rounded-lg bg-brand-600 px-3 py-2 text-sm font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500 sm:px-4"
className={cx(buttonStyles.base, buttonStyles.size.medium, buttonStyles.variant.primary, "ml-1 gap-1.5 px-3")}
>
<CalendarRange className="size-4 shrink-0" strokeWidth={2} aria-hidden />
<CalendarRange className={buttonStyles.icon.medium} aria-hidden />
<span className="sm:hidden">Planner</span>
<span className="hidden sm:inline">Open planner</span>
</Link>
@ -102,7 +107,7 @@ export function SiteHeader({ active = "home", subtitle, rightSlot }: SiteHeaderP
{rightSlot}
<ThemeToggle />
<ThemeToggle appearance="segmented" size="small" />
</div>
</header>
);

View file

@ -1,53 +0,0 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useSyncExternalStore } from "react";
const STORAGE_KEY = "plan-my-meals:theme";
const EVENT_NAME = "plan-my-meals:theme-change";
type ResolvedTheme = "light" | "dark";
function getSnapshot(): ResolvedTheme {
const explicit = document.documentElement.dataset.theme;
if (explicit === "light" || explicit === "dark") return explicit;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function subscribe(callback: () => void): () => void {
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", callback);
window.addEventListener(EVENT_NAME, callback);
return () => {
mq.removeEventListener("change", callback);
window.removeEventListener(EVENT_NAME, callback);
};
}
export function ThemeToggle() {
// Server snapshot returns null so hydration never mismatches; button renders a stable shell first.
const theme = useSyncExternalStore<ResolvedTheme | null>(subscribe, getSnapshot, () => null);
function toggleTheme() {
const next: ResolvedTheme = (theme ?? getSnapshot()) === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = next;
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch {
// Ignore blocked storage; dataset.theme still updates the active session.
}
window.dispatchEvent(new Event(EVENT_NAME));
}
return (
<button
type="button"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Switch to light paper theme" : "Switch to dark slate theme"}
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
className="grid size-10 shrink-0 place-items-center rounded-lg border border-line bg-surface text-ink-muted transition-colors hover:border-line-strong hover:bg-raised hover:text-ink"
>
{theme === "dark" ? <Sun className="size-4" strokeWidth={1.75} aria-hidden /> : <Moon className="size-4" strokeWidth={1.75} aria-hidden />}
</button>
);
}

View file

@ -1,57 +1,95 @@
@import "tailwindcss";
@import "../styles/globals.css";
/* ---------------------------------------------------------------------------
Warm Culinary Editorial Theme ("Parchment & Terracotta" / "Cast-Iron & Peach")
---------------------------------------------------------------------------
Perceptually uniform OKLCH tokens paired with CSS `light-dark()`.
Every foreground/background pair is verified against WCAG 2.2 AA:
- Body & 12px metadata (`ink`, `ink-muted`, `ink-faint`) >= 4.5:1
- Interactive borders (`line-strong`, `selected-line`) >= 3.0:1 (SC 1.4.11)
Plan my Meals BoardUI theme: cool slate neutrals with a salmon accent.
BoardUI's surfaces, text and borders all read the neutral ramp, and every
CTA / selection / focus state reads the accent ramp, so retinting these two
ramps restyles the whole system (BoardUI's documented re-tint point).
Accent 500/600 stay dark enough for white button labels (>= 4.5:1 blended).
--------------------------------------------------------------------------- */
@theme {
/* Surfaces: warm unbleached linen in light mode, cast-iron slate in dark */
--color-canvas: light-dark(oklch(97.4% 0.010 81.8), oklch(20.0% 0.021 245.9));
--color-surface: light-dark(oklch(99.4% 0.006 84.6), oklch(24.5% 0.028 244.8));
--color-raised: light-dark(oklch(94.3% 0.017 79.4), oklch(30.0% 0.035 242.7));
--color-line: light-dark(oklch(84.6% 0.029 81.5), oklch(37.6% 0.042 242.8));
--color-line-strong: light-dark(oklch(56.7% 0.032 74.0), oklch(62.5% 0.046 239.9));
--color-neutral-50: oklch(98.4% 0.003 247.9);
--color-neutral-100: oklch(97.4% 0.005 247.9);
--color-neutral-200: oklch(93.8% 0.010 252.0);
--color-neutral-300: oklch(86.9% 0.020 252.9);
--color-neutral-400: oklch(70.4% 0.034 256.8);
--color-neutral-500: oklch(55.4% 0.040 257.4);
--color-neutral-600: oklch(44.6% 0.038 257.3);
--color-neutral-700: oklch(37.2% 0.036 257.3);
--color-neutral-800: oklch(26.5% 0.032 260.0);
--color-neutral-900: oklch(20.5% 0.030 262.0);
--color-neutral-925: oklch(16.5% 0.028 263.0);
--color-neutral-950: oklch(13.0% 0.026 264.7);
/* Typography ink hierarchy */
--color-ink: light-dark(oklch(22.8% 0.016 62.5), oklch(96.4% 0.021 79.1));
--color-ink-muted: light-dark(oklch(40.7% 0.024 64.6), oklch(84.1% 0.017 250.9));
--color-ink-faint: light-dark(oklch(48.0% 0.026 67.1), oklch(72.6% 0.028 251.7));
--color-accent-50: oklch(97.3% 0.013 27);
--color-accent-100: oklch(94.0% 0.030 27);
--color-accent-200: oklch(88.5% 0.060 28);
--color-accent-300: oklch(81.5% 0.100 29);
--color-accent-400: oklch(72.5% 0.140 30);
--color-accent-500: oklch(60.5% 0.155 31);
--color-accent-600: oklch(53.5% 0.150 32);
--color-accent-700: oklch(46.5% 0.130 32);
--color-accent-800: oklch(39.5% 0.105 32);
--color-accent-900: oklch(32.0% 0.080 31);
--color-accent-950: oklch(23.0% 0.055 30);
}
/* Primary culinary accent: Terracotta Paprika (light) / Warm Peach (dark) */
--color-brand-300: light-dark(oklch(49.7% 0.151 37.0), oklch(82.9% 0.092 53.1));
--color-brand-400: light-dark(oklch(51.8% 0.155 37.0), oklch(81.8% 0.098 52.8));
--color-brand-500: light-dark(oklch(48.1% 0.146 36.8), oklch(85.8% 0.088 52.7));
--color-brand-600: light-dark(oklch(54.0% 0.159 37.1), oklch(80.8% 0.102 52.6));
--color-brand-950: light-dark(oklch(95.4% 0.025 65.6), oklch(26.8% 0.011 36.5));
--color-brand-border: light-dark(oklch(71.0% 0.105 44.7), oklch(52.8% 0.088 49.3));
--color-on-accent: light-dark(oklch(98.8% 0.009 67.7), oklch(18.8% 0.013 55.5));
/* ---------------------------------------------------------------------------
App token aliases → BoardUI semantic tokens.
The app's components were written against these names; pointing them at
BoardUI's tokens gives every screen the BoardUI look and its `.dark` flip.
`inline` makes each utility read the BoardUI variable where it is used.
--------------------------------------------------------------------------- */
@theme inline {
--color-canvas: var(--color-background-full);
--color-surface: var(--color-background-primary-default);
--color-raised: var(--color-background-secondary-default);
--color-line: var(--color-separator-border);
--color-line-strong: var(--color-border-button-hover);
/* Selected control fill & border (>= 4.4:1 boundary contrast) */
--color-selected: light-dark(oklch(90.8% 0.023 78.2), oklch(35.3% 0.043 243.0));
--color-selected-line: light-dark(oklch(49.8% 0.034 72.0), oklch(71.7% 0.046 240.1));
--color-ink: var(--color-text-primary);
/* BoardUI's text-tertiary (neutral-400 light / 600 dark) is ~2.5:1, too faint for body copy,
so the app's two quiet text levels use their own steps that stay >= 4.5:1 (set below). */
--color-ink-muted: var(--pm-ink-muted);
--color-ink-faint: var(--pm-ink-faint);
/* Semantic status tokens (Herb Sage / Saffron Ochre / Chili) */
--color-status-ok: light-dark(oklch(44.7% 0.092 154.8), oklch(84.0% 0.149 155.5));
--color-status-ok-bg: light-dark(oklch(95.5% 0.018 161.1), oklch(27.6% 0.040 162.9));
--color-status-ok-line: light-dark(oklch(69.9% 0.094 157.2), oklch(52.0% 0.101 156.2));
/* Accent: BoardUI's accent ramp; text-on-surface uses the ghost foreground, which flips in dark. */
--color-brand-300: var(--color-button-ghost-foreground);
--color-brand-600: var(--color-accent-500);
--color-brand-950: var(--color-button-ghost-background);
--color-brand-border: color-mix(in oklab, var(--color-accent-500) 45%, transparent);
--color-on-accent: var(--color-text-white);
--color-status-warn: light-dark(oklch(47.6% 0.106 63.3), oklch(88.3% 0.120 85.9));
--color-status-warn-bg: light-dark(oklch(96.7% 0.029 82.6), oklch(26.8% 0.034 78.8));
--color-status-warn-line: light-dark(oklch(72.6% 0.128 75.7), oklch(55.4% 0.107 76.3));
--color-selected: var(--color-background-tertiary-default);
--color-selected-line: var(--color-accent-500);
--color-status-danger: light-dark(oklch(48.1% 0.167 26.2), oklch(80.8% 0.103 19.6));
--color-status-danger-bg: light-dark(oklch(95.2% 0.021 17.5), oklch(24.8% 0.043 13.6));
--color-status-danger-line: light-dark(oklch(68.1% 0.127 21.0), oklch(49.1% 0.133 18.8));
--color-status-ok: var(--color-notification-success-foreground);
--color-status-ok-bg: var(--color-notification-success-background);
--color-status-ok-line: color-mix(in oklab, var(--color-notification-success-foreground) 45%, transparent);
/* Three-voice editorial typography */
--font-sans: var(--font-jakarta), system-ui, sans-serif;
--font-serif: var(--font-fraunces), Georgia, Cambria, serif;
--font-mono: var(--font-jb-mono), ui-monospace, SFMono-Regular, monospace;
--color-status-warn: var(--color-status-yellow-text);
--color-status-warn-bg: var(--color-status-yellow-background);
--color-status-warn-line: color-mix(in oklab, var(--color-status-yellow-text) 45%, transparent);
--color-status-danger: var(--color-notification-error-foreground);
--color-status-danger-bg: var(--color-notification-error-background);
--color-status-danger-line: color-mix(in oklab, var(--color-notification-error-foreground) 45%, transparent);
/* BoardUI is single-family (Inter); the old serif display voice folds into it. */
--font-serif: var(--font-sans);
}
:root {
--pm-ink-muted: var(--color-neutral-600);
--pm-ink-faint: var(--color-neutral-500);
}
.dark {
--pm-ink-muted: var(--color-neutral-300);
--pm-ink-faint: var(--color-neutral-400);
}
@theme {
/* Animations */
--animate-fade-in: fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1);
--animate-preview-timer: preview-timer 1s linear forwards;
@ -83,14 +121,10 @@
@layer base {
html {
color-scheme: light dark;
}
html[data-theme="light"] {
color-scheme: light;
}
html[data-theme="dark"] {
html.dark {
color-scheme: dark;
}
@ -100,7 +134,7 @@
/* Visible focus for keyboard users on every interactive element. */
:where(button, a, input, select, summary, [tabindex]):focus-visible {
@apply outline-2 outline-offset-2 outline-brand-600;
@apply outline-2 outline-offset-2 outline-border-focus-ring;
}
input[type="range"] {
@ -172,12 +206,6 @@
text-box-edge: cap alphabetic;
}
/* Subtle tactile paper noise texture without GPU-heavy blur blobs */
.paper-grain {
background-image: radial-gradient(color-mix(in oklab, var(--color-ink) 5%, transparent) 1px, transparent 0);
background-size: 18px 18px;
}
/* Market receipt serrated / perforated divider feel */
.receipt-rule {
border-top: 1px dashed var(--color-line-strong);

View file

@ -1,21 +1,10 @@
import type { Metadata, Viewport } from "next";
import { Fraunces, JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google";
import { Inter, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const jakarta = Plus_Jakarta_Sans({
variable: "--font-jakarta",
subsets: ["latin"],
});
const fraunces = Fraunces({
variable: "--font-fraunces",
subsets: ["latin"],
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jb-mono",
subsets: ["latin"],
});
// BoardUI's theme reads --font-inter (sans) and --font-mono-source (mono).
const inter = Inter({ variable: "--font-inter", subsets: ["latin"], axes: ["opsz"] });
const jetbrainsMono = JetBrains_Mono({ variable: "--font-mono-source", subsets: ["latin"] });
export const metadata: Metadata = {
// Pages set just their own name ("Recipes"); the template adds the app name.
@ -25,20 +14,22 @@ export const metadata: Metadata = {
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#faf6ef" },
{ media: "(prefers-color-scheme: dark)", color: "#0e171f" },
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
{ media: "(prefers-color-scheme: dark)", color: "#0d1219" },
],
};
const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem("plan-my-meals:theme");if(t==="light"||t==="dark")document.documentElement.dataset.theme=t;}catch(e){}})();`;
// Runs before paint so the BoardUI `.dark` class is set without a flash. Carries over the
// pre-BoardUI "plan-my-meals:theme" choice once so returning users keep their theme.
const THEME_INIT_SCRIPT = `(function(){try{var s=localStorage,t=s.getItem("boardui:theme");if(!t){t=s.getItem("plan-my-meals:theme");if(t)s.setItem("boardui:theme",t);}if(t==="dark")document.documentElement.classList.add("dark");}catch(e){}})();`;
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" suppressHydrationWarning className={`${jakarta.variable} ${fraunces.variable} ${jetbrainsMono.variable}`}>
<html lang="en" suppressHydrationWarning className={`${inter.variable} ${jetbrainsMono.variable} antialiased`}>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body className="min-h-dvh font-sans">{children}</body>
<body className="min-h-dvh bg-background-full font-sans text-text-primary">{children}</body>
</html>
);
}

View file

@ -28,8 +28,8 @@ export const CATALOG: StoreItem[] = [
// --- Meat & Eggs ---------------------------------------------------------
{ id: "chicken_breast", name: "Boneless Skinless Chicken Breast", aisle: "Meat & Eggs", packageSize: 2.5 * LB, baseUnit: "g", packageLabel: "2.5 lb tray", price: 10.5 },
{ id: "chicken_thighs", name: "Boneless Skinless Chicken Thighs", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb tray", price: 9.89 },
{ id: "ground_beef", name: "80/20 Ground Beef", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 6.49 },
{ id: "ground_turkey", name: "93/7 Ground Turkey", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.99 },
{ id: "ground_beef", name: "80/20 Ground Beef", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 6.49 },
{ id: "ground_turkey", name: "93/7 Ground Turkey", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.99 },
{ id: "pork_shoulder", name: "Bone-In Pork Shoulder Roast", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "~3 lb roast", price: 8.39 },
{ id: "smoked_sausage", name: "Smoked Sausage Rope", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz", price: 3.79 },
{ id: "bacon", name: "Hickory Smoked Bacon", aisle: "Meat & Eggs", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz pack", price: 5.09 },
@ -38,7 +38,7 @@ export const CATALOG: StoreItem[] = [
// --- Dairy -----------------------------------------------------------------
{ id: "milk", name: "2% Reduced Fat Milk", aisle: "Dairy", packageSize: 128 * FL_OZ, baseUnit: "ml", packageLabel: "1 gallon", price: 3.59 },
{ id: "butter", name: "Salted Butter Sticks", aisle: "Dairy", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb (4 sticks)", price: 3.96, gramsPerMl: 0.96 },
{ id: "butter", name: "Salted Butter Sticks", aisle: "Dairy", packageSize: LB, baseUnit: "g", packageLabel: "1 lb (4 sticks)", price: 3.96, gramsPerMl: 0.96 },
{ id: "cheddar", name: "Shredded Mild Cheddar", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
{ id: "mozzarella", name: "Shredded Low-Moisture Mozzarella", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 3.09, gramsPerMl: 0.48 },
{ id: "parmesan", name: "Grated Parmesan", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz shaker", price: 3.99, gramsPerMl: 0.42 },
@ -66,11 +66,11 @@ export const CATALOG: StoreItem[] = [
// --- Pantry: grains, cans, baking -------------------------------------------
{ id: "white_rice", name: "Long Grain White Rice", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.24, gramsPerMl: 0.78 },
{ id: "spaghetti", name: "Spaghetti", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "penne", name: "Penne Rigate", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "elbow_macaroni", name: "Elbow Macaroni", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "spaghetti", name: "Spaghetti", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "penne", name: "Penne Rigate", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "elbow_macaroni", name: "Elbow Macaroni", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.38 },
{ id: "oats", name: "Quick Oats", aisle: "Pantry", packageSize: 42 * OZ, baseUnit: "g", packageLabel: "42 oz canister", price: 4.19, gramsPerMl: 0.34 },
{ id: "red_lentils", name: "Dry Lentils", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz bag", price: 1.79, gramsPerMl: 0.8 },
{ id: "red_lentils", name: "Dry Lentils", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz bag", price: 1.79, gramsPerMl: 0.8 },
{ id: "flour_tortillas", name: "Flour Tortillas (Taco Size)", aisle: "Bakery", packageSize: 10, baseUnit: "each", packageLabel: "10 ct", price: 2.49 },
{ id: "sandwich_bread", name: "White Sandwich Bread", aisle: "Bakery", packageSize: 20, baseUnit: "each", packageLabel: "20 oz loaf (~20 slices)", price: 2.29 },
{ id: "breadcrumbs", name: "Italian Breadcrumbs", aisle: "Pantry", packageSize: 15 * OZ, baseUnit: "g", packageLabel: "15 oz canister", price: 1.99, gramsPerMl: 0.45 },
@ -122,12 +122,13 @@ export const CATALOG: StoreItem[] = [
// --- Cayden's Corner (family recipes) --------------------------------------------
// Meat & seafood
{ id: "italian_sausage", name: "Mild Italian Sausage (bulk)", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.69 },
{ id: "italian_sausage", name: "Mild Italian Sausage (bulk)", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 4.69 },
{ id: "turkey_sausage", name: "Smoked Turkey Sausage", aisle: "Meat & Eggs", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz rope", price: 3.79 },
{ id: "pork_tenderloin", name: "Pork Tenderloin", aisle: "Meat & Eggs", packageSize: 1.25 * LB, baseUnit: "g", packageLabel: "~1.25 lb", price: 6.49 },
{ id: "bottom_round_roast", name: "Beef Bottom Round Roast", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "per lb", price: 7.49 },
{ id: "rib_roast", name: "Beef Standing Rib Roast", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "per lb", price: 15.99 },
{ id: "bottom_round_roast", name: "Beef Bottom Round Roast", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "per lb", price: 7.49 },
{ id: "rib_roast", name: "Beef Standing Rib Roast", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "per lb", price: 15.99 },
{ id: "spiral_ham", name: "Spiral-Sliced Bone-In Half Ham", aisle: "Meat & Eggs", packageSize: 8 * LB, baseUnit: "g", packageLabel: "~8 lb", price: 21.99 },
{ id: "ham_steak", name: "Bone-In Ham Steak", aisle: "Meat & Eggs", packageSize: 1, baseUnit: "each", packageLabel: "~1 lb steak", price: 5.49 },
{ id: "deli_ham", name: "Sliced Deli Ham", aisle: "Meat & Eggs", packageSize: 9 * OZ, baseUnit: "g", packageLabel: "9 oz tub", price: 4.29 },
{ id: "bacon_bits", name: "Real Bacon Bits", aisle: "Pantry", packageSize: 3 * OZ, baseUnit: "g", packageLabel: "3 oz pouch", price: 3.29, gramsPerMl: 0.3 },
{ id: "canned_crab", name: "Lump Crabmeat, canned", aisle: "Seafood", packageSize: 6 * OZ, baseUnit: "g", packageLabel: "6 oz can", price: 6.99 },
@ -162,9 +163,9 @@ export const CATALOG: StoreItem[] = [
{ id: "crackers", name: "Butter Crackers", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "13.7 oz box", price: 3.49 },
{ id: "saltines", name: "Saltine Crackers", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz box", price: 2.99 },
// Baking
{ id: "baking_soda", name: "Baking Soda", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb box", price: 1.19, gramsPerMl: 1.0, pantryStaple: true },
{ id: "baking_soda", name: "Baking Soda", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "1 lb box", price: 1.19, gramsPerMl: 1.0, pantryStaple: true },
{ id: "vanilla_extract", name: "Vanilla Extract", aisle: "Pantry", packageSize: 2 * FL_OZ, baseUnit: "ml", packageLabel: "2 oz bottle", price: 5.49 },
{ id: "mint_extract", name: "Pure Peppermint Extract", aisle: "Pantry", packageSize: 1 * FL_OZ, baseUnit: "ml", packageLabel: "1 oz bottle", price: 3.99 },
{ id: "mint_extract", name: "Pure Peppermint Extract", aisle: "Pantry", packageSize: FL_OZ, baseUnit: "ml", packageLabel: "1 oz bottle", price: 3.99 },
{ id: "powdered_sugar", name: "Powdered Sugar", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.59, gramsPerMl: 0.5 },
{ id: "shortening", name: "Vegetable Shortening", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz can", price: 3.79, gramsPerMl: 0.8 },
{ id: "molasses", name: "Molasses", aisle: "Pantry", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 4.29 },
@ -191,9 +192,9 @@ export const CATALOG: StoreItem[] = [
// Meat & deli
{ id: "rotisserie_chicken", name: "Rotisserie Chicken (meat only)", aisle: "Meat & Eggs", packageSize: 1.5 * LB, baseUnit: "g", packageLabel: "1 whole bird (~1.5 lb meat)", price: 6.99, gramsPerMl: 0.59 },
{ id: "chicken_drumsticks", name: "Chicken Drumsticks", aisle: "Meat & Eggs", packageSize: 3 * LB, baseUnit: "g", packageLabel: "3 lb family pack", price: 5.22 },
{ id: "ground_chicken", name: "Ground Chicken", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.99 },
{ id: "ground_chicken", name: "Ground Chicken", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.99 },
{ id: "pork_chops", name: "Boneless Pork Chops", aisle: "Meat & Eggs", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb pack", price: 9.1 },
{ id: "beef_stew_meat", name: "Beef Stew Meat", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb tray", price: 7.99 },
{ id: "beef_stew_meat", name: "Beef Stew Meat", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb tray", price: 7.99 },
{ id: "diced_ham", name: "Diced Ham", aisle: "Meat & Eggs", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz pack", price: 3.29, gramsPerMl: 0.6 },
{ id: "hot_dogs", name: "Beef Franks", aisle: "Meat & Eggs", packageSize: 8, baseUnit: "each", packageLabel: "8 ct", price: 4.49 },
{ id: "frozen_meatballs", name: "Frozen Homestyle Meatballs", aisle: "Frozen", packageSize: 26 * OZ, baseUnit: "g", packageLabel: "26 oz bag", price: 6.49 },
@ -236,7 +237,7 @@ export const CATALOG: StoreItem[] = [
{ id: "naan", name: "Garlic Naan", aisle: "Bakery", packageSize: 4, baseUnit: "each", packageLabel: "4 ct", price: 4.29 },
// Pantry: grains & pasta
{ id: "egg_noodles", name: "Wide Egg Noodles", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 2.19 },
{ id: "lasagna_noodles", name: "Lasagna Noodles", aisle: "Pantry", packageSize: 1 * LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.89 },
{ id: "lasagna_noodles", name: "Lasagna Noodles", aisle: "Pantry", packageSize: LB, baseUnit: "g", packageLabel: "16 oz box", price: 1.89 },
{ id: "rice_noodles", name: "Thin Rice Noodles", aisle: "International", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz pack", price: 2.99 },
{ id: "quinoa", name: "Quinoa", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 3.99, gramsPerMl: 0.72 },
{ id: "couscous", name: "Couscous", aisle: "Pantry", packageSize: 10 * OZ, baseUnit: "g", packageLabel: "10 oz box", price: 2.79, gramsPerMl: 0.73 },
@ -275,9 +276,9 @@ export const CATALOG: StoreItem[] = [
{ id: "red_pepper_flakes", name: "Crushed Red Pepper", aisle: "Spices & Sauces", packageSize: 1.5 * OZ, baseUnit: "g", packageLabel: "1.5 oz", price: 1.89, gramsPerMl: 0.4 },
// --- Added for the expanded cuisines ------------------------------------------
{ id: "ground_pork", name: "Ground Pork", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.19 },
{ id: "breakfast_sausage", name: "Original Pork Sausage Roll", aisle: "Meat & Eggs", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb roll", price: 3.99 },
{ id: "shrimp", name: "Frozen Raw Shrimp, peeled & deveined", aisle: "Seafood", packageSize: 1 * LB, baseUnit: "g", packageLabel: "1 lb bag", price: 7.99 },
{ id: "ground_pork", name: "Ground Pork", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb pack", price: 4.19 },
{ id: "breakfast_sausage", name: "Original Pork Sausage Roll", aisle: "Meat & Eggs", packageSize: LB, baseUnit: "g", packageLabel: "1 lb roll", price: 3.99 },
{ id: "shrimp", name: "Frozen Raw Shrimp, peeled & deveined", aisle: "Seafood", packageSize: LB, baseUnit: "g", packageLabel: "1 lb bag", price: 7.99 },
{ id: "feta", name: "Crumbled Feta", aisle: "Dairy", packageSize: 4 * OZ, baseUnit: "g", packageLabel: "4 oz tub", price: 3.29, gramsPerMl: 0.53 },
{ id: "cucumber", name: "Cucumber", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.89 },
{ id: "roma_tomatoes", name: "Roma Tomato", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.48 },

View file

@ -20,7 +20,7 @@ const FRACTIONS: [number, string][] = [
];
/** 0.75 → "¾", 1.5 → "1½", 2.33 → "2⅓", 13.2 → "13". */
export function formatQuantity(value: number): string {
function formatQuantity(value: number): string {
if (value >= 10) return String(Math.round(value));
const whole = Math.floor(value);
const fraction = value - whole;
@ -30,7 +30,7 @@ export function formatQuantity(value: number): string {
return `${total > 0 ? total : ""}${glyph}` || "0";
}
export interface IngredientLine {
interface IngredientLine {
quantity: string;
/** e.g. "cup", "lb", or "×" for counted items. */
unit: string;
@ -39,7 +39,7 @@ export interface IngredientLine {
detail?: string;
}
export function ingredientLine(ingredient: RecipeIngredient, scale: number): IngredientLine {
function ingredientLine(ingredient: RecipeIngredient, scale: number): IngredientLine {
const item = CATALOG_BY_ID.get(ingredient.itemId);
const name = item?.name ?? ingredient.itemId;
const quantity = formatQuantity(ingredient.amount * scale);

View file

@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test";
import { jsPDF } from "jspdf";
import { buildPlanModel, pdfSafe, renderPlanPdf } from "./exportPlan";
import { RECIPES } from "./recipes";
const breakfast = RECIPES.find((r) => r.kind === "breakfast")!;
const dinner = RECIPES.find((r) => r.kind === "main")!;
const state = {
daysCount: 2,
mealsPerDay: 3,
householdSize: 4,
// Monday breakfast + dinner, Tuesday dinner; "6-3" is outside daysCount and must not export.
slots: { "0-1": breakfast.id, "0-3": dinner.id, "1-3": dinner.id, "6-3": dinner.id },
};
describe("plan export", () => {
const model = buildPlanModel(state);
test("organizes meals under each planned day, in meal order", () => {
expect(model.days.map((d) => d.name)).toEqual(["Monday", "Tuesday"]);
const [monday, tuesday] = model.days;
expect(monday.meals.map((m) => ("empty" in m ? `${m.label}: -` : `${m.label}: ${m.title}`))).toEqual([
`Breakfast: ${breakfast.title}`,
"Lunch: -",
`Dinner: ${dinner.title}`,
]);
expect(tuesday.meals.filter((m) => !("empty" in m))).toHaveLength(1);
});
test("each meal carries its full recipe, scaled to the household", () => {
const meal = model.days[0].meals[0];
if ("empty" in meal) throw new Error("expected a planned meal");
expect(meal.steps).toEqual(breakfast.steps);
expect(meal.ingredients).toHaveLength(breakfast.ingredients.length);
expect(meal.meta).toContain("serves 4");
});
test("text is made printable in the base PDF font", () => {
expect(pdfSafe("1⅓ cup flour")).toBe("1 1/3 cup flour");
expect(pdfSafe("⅛ tsp salt")).toBe("1/8 tsp salt");
expect(pdfSafe("½ cup · 350°F — Rakı")).toBe("½ cup · 350°F — Raki");
});
test("renders a real multi-page PDF (one day per page)", () => {
const bytes = new Uint8Array(renderPlanPdf(model, jsPDF).output("arraybuffer"));
expect(new TextDecoder().decode(bytes.slice(0, 5))).toBe("%PDF-");
expect(renderPlanPdf(model, jsPDF).getNumberOfPages()).toBeGreaterThanOrEqual(2);
});
});

170
app/lib/exportPlan.ts Normal file
View file

@ -0,0 +1,170 @@
import type { jsPDF as JsPDF } from "jspdf";
import type { MealPlanState } from "@/app/types";
import { ingredientLines } from "./display";
import { activeSlots, mealLabel } from "./plan";
import { RECIPES_BY_ID } from "./recipes";
// ---------------------------------------------------------------------------
// Downloadable weekly plan as a PDF, organized by day, with the full recipe
// (scaled ingredients + steps) under every planned meal.
//
// Two layers: `buildPlanModel` (pure, tested) decides the content;
// `renderPlanPdf` lays it out with jsPDF. The planner loads jsPDF on click.
// ---------------------------------------------------------------------------
const FULL_DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
interface PlannedMeal {
label: string;
title: string;
meta: string;
description: string;
ingredients: string[];
steps: string[];
tip?: string;
}
interface PlanModel {
intro: string;
days: { name: string; meals: (PlannedMeal | { label: string; empty: true })[] }[];
}
type PlanInput = Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots" | "householdSize">;
export function buildPlanModel(state: PlanInput): PlanModel {
const servings = state.householdSize;
const slots = activeSlots(state);
return {
intro: `${state.daysCount} day${state.daysCount === 1 ? "" : "s"} · ingredients scaled for ${servings} ${
servings === 1 ? "person" : "people"
}. Step wording follows each recipe's original batch size.`,
days: FULL_DAY_NAMES.slice(0, state.daysCount).map((name, dayIndex) => ({
name,
meals: slots
.filter((s) => s.dayIndex === dayIndex)
.map((s) => {
const label = mealLabel(s.mealNumber);
const recipe = s.recipeId ? RECIPES_BY_ID.get(s.recipeId) : undefined;
if (!recipe) return { label, empty: true as const };
return {
label,
title: recipe.title,
meta: `${recipe.prepTime} min · serves ${servings}`,
description: recipe.description,
ingredients: ingredientLines(recipe, servings).map(
(l) => `${l.quantity} ${l.unit} ${l.name}${l.detail ? ` (${l.detail})` : ""}`,
),
steps: recipe.steps,
tip: recipe.tip,
};
}),
})),
};
}
// jsPDF's built-in Helvetica only covers WinAnsi (Latin-1 + a few symbols).
const SPELLED: Record<string, string> = { "⅓": " 1/3", "⅔": " 2/3", "⅛": " 1/8", "ı": "i" };
const WINANSI_EXTRA = new Set("€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ");
/** Make text printable in the base PDF font: spell out ⅓-style fractions, decompose or drop anything else. */
export function pdfSafe(text: string): string {
let out = "";
for (const ch of text) {
const code = ch.codePointAt(0)!;
if (code <= 0x7e || (code >= 0xa0 && code <= 0xff) || WINANSI_EXTRA.has(ch)) out += ch;
else if (SPELLED[ch]) out += SPELLED[ch];
else out += ch.normalize("NFKD").replace(/[^\x20-\x7e]/g, "");
}
// "⅓ cup" → " 1/3 cup": drop the space the spelling adds when nothing precedes it.
return out.replace(/(^|\s) (\d\/\d)/g, "$1$2");
}
const ACCENT: [number, number, number] = [206, 87, 68]; // matches the app's salmon accent-500
const INK: [number, number, number] = [30, 37, 48];
const MUTED: [number, number, number] = [74, 85, 101];
/** Lay the plan out on US Letter pages; one day per page, recipes never split mid-line. */
export function renderPlanPdf(model: PlanModel, JsPdf: typeof JsPDF): JsPDF {
const doc = new JsPdf({ unit: "pt", format: "letter" });
const pageW = doc.internal.pageSize.getWidth();
const pageH = doc.internal.pageSize.getHeight();
const margin = 54;
const width = pageW - margin * 2;
let y = margin;
const ensure = (height: number) => {
if (y + height > pageH - margin) {
doc.addPage();
y = margin;
}
};
const write = (text: string, opts: { size: number; bold?: boolean; color?: [number, number, number]; indent?: number; gap?: number }) => {
const indent = opts.indent ?? 0;
const lineH = opts.size * 1.4;
doc.setFont("helvetica", opts.bold ? "bold" : "normal").setFontSize(opts.size).setTextColor(...(opts.color ?? INK));
for (const line of doc.splitTextToSize(pdfSafe(text), width - indent) as string[]) {
ensure(lineH);
doc.text(line, margin + indent, y + opts.size);
y += lineH;
}
y += opts.gap ?? 0;
};
/** Numbered/bulleted item with a hanging indent so wrapped lines align under the text. */
const item = (marker: string, text: string) => {
const size = 10.5;
const lineH = size * 1.4;
doc.setFont("helvetica", "normal").setFontSize(size).setTextColor(...INK);
const lines = doc.splitTextToSize(pdfSafe(text), width - 20) as string[];
lines.forEach((line, i) => {
ensure(lineH);
if (i === 0) doc.text(marker, margin + 4, y + size);
doc.text(line, margin + 20, y + size);
y += lineH;
});
y += 2;
};
write("My meal plan", { size: 24, bold: true, gap: 4 });
write(model.intro, { size: 10.5, color: MUTED, gap: 16 });
model.days.forEach((day, dayIndex) => {
if (dayIndex > 0) {
doc.addPage();
y = margin;
}
write(day.name, { size: 18, bold: true, gap: 2 });
doc.setDrawColor(...ACCENT).setLineWidth(1.5).line(margin, y, margin + width, y);
y += 14;
for (const meal of day.meals) {
ensure(80); // keep a meal's heading with the start of its recipe
write(meal.label.toUpperCase(), { size: 9, bold: true, color: ACCENT, gap: 2 });
if ("empty" in meal) {
write("Nothing planned", { size: 10.5, color: MUTED, gap: 16 });
continue;
}
write(meal.title, { size: 14, bold: true });
write(meal.meta, { size: 9.5, color: MUTED, gap: 4 });
write(meal.description, { size: 10.5, gap: 8 });
write("Ingredients", { size: 11, bold: true, gap: 2 });
meal.ingredients.forEach((line) => item("•", line));
y += 6;
write("Steps", { size: 11, bold: true, gap: 2 });
meal.steps.forEach((step, i) => item(`${i + 1}.`, step));
if (meal.tip) {
y += 4;
write(`Tip: ${meal.tip}`, { size: 10, color: MUTED });
}
y += 18;
}
});
const pages = doc.getNumberOfPages();
for (let page = 1; page <= pages; page++) {
doc.setPage(page).setFont("helvetica", "normal").setFontSize(8.5).setTextColor(...MUTED);
doc.text(`Plan my Meals · page ${page} of ${pages}`, pageW / 2, pageH - 28, { align: "center" });
}
return doc;
}

View file

@ -23,7 +23,7 @@ import { toBaseUnit } from "./units";
// the recipe's own ingredients instead of rebuilding the list.
// ---------------------------------------------------------------------------
export interface GroceryOptions {
interface GroceryOptions {
householdSize: number;
/** Items already in the kitchen: listed, but cost $0. */
onHandItems: ItemId[];

View file

@ -56,7 +56,7 @@ export const slotKey = (dayIndex: number, mealNumber: number) => `${dayIndex}-${
* So changing the count never moves a meal to a different slot, and meals
* hidden by a lower count come back when you raise it again (like days do).
*/
export const MEAL_NAMES = ["Breakfast", "Lunch", "Dinner"] as const;
const MEAL_NAMES = ["Breakfast", "Lunch", "Dinner"] as const;
/** Visible meal numbers for a count, e.g. 2 → [2, 3]. */
export function visibleMeals(mealsPerDay: number): number[] {
@ -107,7 +107,7 @@ export function activeRecipeIds(state: Pick<MealPlanState, "daysCount" | "mealsP
// --- Can this recipe be planned? ---------------------------------------------
export interface RecipeBlockers {
interface RecipeBlockers {
/** Appliance labels the user doesn't have, e.g. ["Oven"]. */
missingAppliances: string[];
/** Store-item names the user chose to avoid, e.g. ["80/20 Ground Beef"]. */

View file

@ -32,7 +32,7 @@ export const CAYDENS_CORNER_MAINS = dishes({ cuisine: "american" }, [
{ id: "family-crispy-pork-cutlets", title: "Crispy Pork Cutlets with Lemon Butter Sauce", mins: 45, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Panko-crusted pork tenderloin with a silky lemon butter sauce.", items: "pork_tenderloin 2.5 lb; flour 2 tbsp; eggs 2 each; panko 3 cup; white_wine 0.5 cup; lemon 1 each; heavy_cream 2 tbsp; shallots 1 each; minced_garlic 0.5 tsp; butter 0.5 cup; vegetable_oil 0.5 cup" },
{ id: "family-curried-chicken-broccoli-rice-casserole", title: "Curried Chicken Broccoli Rice Casserole", mins: 60, serves: 8, tools: ["stovetop", "oven"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Curry-mayo chicken and broccoli under cheddar and French-fried onions, over rice.", tip: "This reheats like a dream and the flavors are even better after they've had time to sit, so consider making a large batch so you'll have leftovers.", items: "chicken_breast 1.5 lb; frozen_broccoli 16 oz; mayo 1.5 cup; curry_powder 2 tbsp; cayenne 1.5 tsp; garlic_powder 1 tsp; cheddar 2.5 cup; cream_of_chicken 2 each; cream_of_mushroom 1 each; french_fried_onions 6 oz; white_rice 0.75 cup" },
{ id: "family-green-chicken-chili-soup", title: "Green Chicken Chili Soup", cuisine: "mexican", mins: 20, serves: 6, tools: ["stovetop", "slow_cooker"], styles: ["caydens_corner", "healthy", "meal_prep"], about: "Salsa verde, white beans and green chiles, slow-cooked with chicken.", items: "olive_oil 2 tbsp; onion 1 each; bell_pepper 1 each; jalapeno 1 each; minced_garlic 2 tsp; chicken_broth 3 cup; white_beans 1 each; salsa_verde 2 cup; green_chiles 1.5 each; chicken_breast 1.5 lb; cumin 1 tsp; chili_powder 1 tsp; salt 1 tsp; lime 1 each; avocado 1 each; cilantro 1 each" },
{ id: "family-ham-cabbage-potato-soup", title: "Ham, Cabbage, and Potato Soup", mins: 35, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Creamy potato and ham soup thickened with a butter-flour roux.", items: "potatoes 1.25 lb; diced_ham 0.75 cup; celery 1 each; onion 0.5 each; chicken_bouillon 2 tbsp; black_pepper 1 tsp; salt 0.5 tsp; butter 5 tbsp; flour 5 tbsp; milk 2 cup" },
{ id: "family-ham-cabbage-potato-soup", title: "Cade's Cabbage Soup", mins: 60, serves: 8, tools: ["stovetop"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "A whole cabbage, chunky skin-on potatoes and bone-in ham, slow-boiled in chicken broth.", tip: "You'll need one large pot with a lid. Buy three 32 oz cartons of broth; you'll probably use about two and a half.", items: "cabbage 1 each; potatoes 3.5 lb; ham_steak 1 each; chicken_broth 10 cup; salt 1 tsp; black_pepper 1 tsp" },
{ id: "family-indian-chicken-curry", title: "Indian Chicken Curry", cuisine: "indian", mins: 40, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Warm-spiced curry with yogurt and coconut milk.", items: "olive_oil 3 tbsp; onion 1 each; minced_garlic 1 tsp; curry_powder 3 tbsp; cinnamon 1 tsp; paprika 1 tsp; ginger_paste 0.5 tsp; sugar 0.5 tsp; chicken_breast 1 lb; tomato_paste 0.1 each; greek_yogurt 1 cup; coconut_milk 0.75 cup; lemon 0.5 each; cayenne 0.5 tsp" },
{ id: "family-japanese-glazed-chicken", title: "Japanese Glazed Chicken", cuisine: "japanese", mins: 15, serves: 4, tools: ["slow_cooker"], styles: ["caydens_corner", "meal_prep"], about: "Drumsticks slow-cooked in a sweet balsamic-soy glaze, over rice.", items: "chicken_drumsticks 2.5 lb; balsamic_vinegar 1 cup; soy_sauce 0.66 cup; sugar 5 tbsp; minced_garlic 1 tsp; jalapeno 2 each; white_rice 1.5 cup" },
{ id: "family-loaded-baked-potato-soup", title: "Loaded Baked Potato Soup", mins: 15, serves: 8, tools: ["slow_cooker"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Dump-and-go crockpot soup with cream cheese, bacon and cheddar.", tip: "Make a large batch of this soup – it reheats extremely well!", items: "frozen_hash_browns 64 oz; cream_cheese 8 oz; cream_of_chicken 2 each; bacon_bits 1 cup; cheddar 1.5 cup; green_onions 1 each; garlic_powder 1 tbsp; chicken_broth 4 cup" },

View file

@ -14,7 +14,7 @@ import type { Appliance, CookingStyle, Cuisine, MealKind, RecipeData, RecipeIngr
// module load, so typos fail loudly in dev instead of producing $0 groceries.
// ---------------------------------------------------------------------------
export interface DishInput {
interface DishInput {
title: string;
/** Defaults to a slug of the title. Set it explicitly to keep saved plans stable if you rename. */
id?: string;
@ -35,7 +35,7 @@ export interface DishInput {
const UNITS = new Set<RecipeUnit>(["g", "oz", "lb", "ml", "tsp", "tbsp", "cup", "each"]);
export const slugify = (text: string) =>
const slugify = (text: string) =>
text
.normalize("NFD") // "Étouffée" → "Etouffee" + combining accents…
.replace(/[\u0300-\u036f]/g, "") // …which this strips

View file

@ -67,7 +67,6 @@ export const EXTRA_SOUTHERN = dishes({ cuisine: "southern" }, [
{ title: "Cajun Shrimp Pasta", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort", "quick"], about: "Spicy cream sauce, plump shrimp.", items: "spaghetti 1 lb; shrimp 1 lb; heavy_cream 1 cup; cajun_seasoning 1 tbsp; parmesan 0.5 cup; minced_garlic 1 tbsp; butter 2 tbsp" },
{ title: "Fish Stick Po' Boys", mins: 20, tools: ["air_fryer"], styles: ["seafood", "quick"], about: "Crispy fish, slaw and spicy mayo on hoagies.", items: "fish_sticks 16 oz; hoagie_rolls 4 each; coleslaw_mix 6 oz; mayo 0.25 cup; hot_sauce 1 tbsp; lemon 1 each" },
{ title: "Smothered Pork Chops", mins: 40, tools: ["stovetop"], styles: ["comfort"], about: "Seared chops buried in onion gravy, over rice.", items: "pork_chops 1.5 lb; onion 2 each; flour 3 tbsp; chicken_broth 2 cup; garlic_powder 1 tsp; white_rice 1.5 cup" },
{ title: "Ham, Bean & Cabbage Soup", mins: 35, tools: ["stovetop"], styles: ["healthy", "comfort"], about: "Smoky beans, ham and cabbage greens.", items: "white_beans 2 each; diced_ham 8 oz; cabbage 0.5 each; onion 1 each; chicken_broth 4 cup; apple_cider_vinegar 1 tbsp" },
{ title: "Shrimp Étouffée", mins: 40, tools: ["stovetop"], styles: ["seafood", "comfort"], about: "Buttery blond roux, shrimp, rice.", items: "shrimp 1 lb; butter 4 tbsp; flour 0.25 cup; onion 1 each; bell_pepper 1 each; celery 1 each; chicken_broth 2 cup; cajun_seasoning 2 tsp; white_rice 1.5 cup" },
{ title: "Low Country Sausage & Cabbage", mins: 30, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Fried cabbage with smoked sausage.", items: "smoked_sausage 13 oz; cabbage 1 each; onion 1 each; butter 2 tbsp; cajun_seasoning 1 tsp" },
{ title: "Slow Cooker Beans, Greens & Cornbread", mins: 15, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Set-and-forget beans and greens.", items: "pinto_beans 3 each; diced_ham 8 oz; cabbage 1 each; onion 1 each; chicken_broth 4 cup; hot_sauce 1 tbsp; cornbread_mix 1 each; eggs 1 each; milk 0.33 cup" },

View file

@ -119,11 +119,15 @@ export const CAYDENS_CORNER_STEPS: Record<string, string[]> = {
"Stir in lime juice. Serve topped with avocado and cilantro.",
],
"family-ham-cabbage-potato-soup": [
"Combine potatoes, 3¼ cups water, ham, celery, and onion in a stockpot. Bring to a boil.",
"Simmer over medium heat until potatoes are tender, 10 to 15 minutes. Stir in chicken bouillon, pepper, and salt.",
"Melt butter in a separate saucepan over medium-low heat. Add flour; cook and stir until thick, about 1 minute.",
"Whisk in milk; cook and stir until thick, 4 to 5 minutes.",
"Pour the milk mixture into the stockpot; cook and stir until warmed through.",
"Wash the cabbage and remove any dirt, and peel back any dead / dying leaves.",
"Chop the cabbage into large leaf chunks and put them in a large pot.",
"Wash 5 whole potatoes and chop them into large cubes. They do not need to be peeled.",
"Put the potatoes in the pot on top of the cabbage.",
"Put in the chicken broth until it is filled to a high level.",
"Boil down the cabbage and potatoes for 20-30 minutes, or until the cabbage is a soft texture.",
"Cut up your ham steak into medium-sized cubes and put in the pot. Add the ham bone as well for extra flavor.",
"Continue the slow boil for an additional 15 minutes before taking off heat.",
"Put in a generous amount of pepper and salt to taste.",
],
"family-indian-chicken-curry": [
"Heat olive oil in a skillet over medium heat. Sauté onion until lightly browned.",

View file

@ -61,7 +61,6 @@ export const EXTRA_AMERICAS_STEPS: Record<string, string[]> = {
"cajun-shrimp-pasta": ["Boil spaghetti.", "Toss shrimp with Cajun seasoning; cook in butter 2 min per side; remove.", "Add garlic and cream; simmer 3 min; stir in parmesan.", "Toss pasta and shrimp in the sauce."],
"fish-stick-po-boys": ["Air fry fish sticks at 400°F 10–12 min.", "Mix mayo with hot sauce and lemon juice.", "Fill rolls with slaw, fish sticks and spicy mayo."],
"smothered-pork-chops": ["Cook rice.", "Season chops; dredge in flour; brown in oil and remove.", "Cook sliced onions in the pan 8 min; sprinkle leftover flour and garlic powder, stir 1 min.", "Whisk in broth; return chops, cover and simmer 15 min. Serve over rice."],
"ham-bean-and-cabbage-soup": ["Brown ham with diced onion.", "Add chopped cabbage, beans and broth; simmer 20 min.", "Stir in vinegar; season."],
"shrimp-etouffee": ["Cook rice.", "Melt butter; whisk in flour; stir 8–10 min until peanut-butter colored.", "Add diced onion, pepper and celery; cook 5 min; whisk in broth and Cajun seasoning; simmer 10 min.", "Add shrimp; cook 3–4 min. Serve over rice."],
"low-country-sausage-and-cabbage": ["Brown sliced sausage in butter; remove.", "Add sliced onion and chopped cabbage; cook 10–12 min until tender and browned in spots.", "Return sausage; season with Cajun seasoning."],
"slow-cooker-beans-greens-and-cornbread": ["Add beans (with liquid), ham, chopped cabbage, onion, broth and hot sauce to the slow cooker.", "Cook on low 6–8 hours.", "Bake cornbread: mix with egg and milk, bake at 400°F 15–20 min.", "Serve beans and greens with cornbread."],

View file

@ -1,53 +1,17 @@
import { ArrowRight, BookHeart, Flame, ReceiptText, SlidersHorizontal } from "lucide-react";
import { RiArrowRightLine, RiHeart3Line, RiRestaurantLine, RiShoppingBasket2Line, RiWallet3Line } from "@remixicon/react";
import Link from "next/link";
import { OverlapExplorer, type OverlapComboSnapshot, type OverlapDish } from "./components/OverlapExplorer";
import { RotatingPreview, type PreviewSet } from "./components/RotatingPreview";
import { Logo, SiteHeader } from "./components/SiteHeader";
import { CATALOG, PANTRY_STAPLE_IDS } from "./lib/catalog";
import { PANTRY_STAPLE_IDS } from "./lib/catalog";
import { buildGroceryList } from "./lib/grocery";
import { APPLIANCES, CUISINES } from "./lib/plan";
import { CUISINES } from "./lib/plan";
import { RECIPES } from "./lib/recipes";
import { formatUsd } from "./lib/units";
import { buttonStyles } from "@/components/base/buttons/button";
import { cx } from "@/utils/cx";
// ---------------------------------------------------------------------------
// Landing page (server component, fully static).
//
// Every statistic, menu preview, and interactive overlap combination is
// derived from the real recipe & store catalog at build time so zero catalog
// payload needs to ship to the landing-page client bundle.
// ---------------------------------------------------------------------------
const DEMO_RECIPE_IDS = ["chicken-curry", "red-lentil-dal", "chana-masala", "garlic-butter-shrimp-and-rice"] as const;
const OVERLAP_DATA = (() => {
const options = { householdSize: 2, onHandItems: PANTRY_STAPLE_IDS };
const dishes: OverlapDish[] = DEMO_RECIPE_IDS.map((id) => {
const r = RECIPES.find((x) => x.id === id)!;
const cuisineLabel = CUISINES.find((c) => c.id === r.cuisine)?.label ?? r.cuisine;
return {
id,
title: r.title,
cuisine: `${cuisineLabel} · ${r.prepTime} min`,
standaloneCheckout: buildGroceryList([id], options).checkoutTotal,
};
});
const combos: Record<string, OverlapComboSnapshot> = {};
const totalMasks = 1 << DEMO_RECIPE_IDS.length;
for (let mask = 1; mask < totalMasks; mask++) {
const picked = DEMO_RECIPE_IDS.filter((_, idx) => (mask & (1 << idx)) !== 0);
const key = [...picked].sort().join(",");
const summary = buildGroceryList(picked, options);
combos[key] = {
checkoutTotal: summary.checkoutTotal,
usedTotal: summary.usedTotal,
carryoverValue: summary.carryoverValue,
sharedNames: summary.lines.filter((l) => l.usedBy.length > 1 && !l.onHand).map((l) => l.item.name),
packageCount: summary.lines.length,
};
}
return { dishes, combos };
})();
// Landing page (server component, fully static). Preview menus are priced from the
// real recipe & store catalog at build time, so no catalog ships to the client.
const PREVIEW_THEMES: { theme: string; blurb: string; meals: [slot: string, recipeId: string][] }[] = [
{ theme: "Comfort classics", blurb: "Cozy & thrifty", meals: [["Mon · Dinner", "chicken-pot-pie"], ["Tue · Lunch", "chicken-noodle-soup"], ["Tue · Dinner", "beef-stroganoff"]] },
@ -79,208 +43,110 @@ const PREVIEW_SETS: PreviewSet[] = PREVIEW_THEMES.map(({ theme, blurb, meals })
const STEPS = [
{
num: "01",
title: "Set your register target",
body: "Choose a weekly grocery ceiling, 1–7 days, meals per day, and household size. Ingredient quantities scale automatically.",
icon: RiWallet3Line,
title: "Tell us your budget",
body: "How much you want to spend, how many people you're feeding, and which days you're cooking. We'll do the crunching for you.",
},
{
num: "02",
title: "Match your real kitchen",
body: "Toggle only the appliances you own—stovetop, air fryer, rice cooker, microwave. Exclude allergens or mark items already on your shelf.",
icon: RiRestaurantLine,
title: "Pick meals you'll love",
body: "Fill the week in one tap or choose dish by dish. We only suggest recipes your kitchen can actually make.",
},
{
num: "03",
title: "Pack the week with overlap",
body: "Auto-fill prioritizes dishes that share open packages of produce, dairy, and aromatics—or pick slot-by-slot sorted by marginal cart cost.",
},
{
num: "04",
title: "Walk the aisles once",
body: "Export one consolidated list grouped by store aisle, priced in whole packages the way checkout actually charges.",
icon: RiShoppingBasket2Line,
title: "Shop with one list",
body: "Get a single grocery list sorted by aisle, priced the way the store really rings it up (based on averages from October 2026, Central USA).",
},
];
const FAMILY_COUNT = RECIPES.filter((r) => r.styles.includes("caydens_corner")).length;
const ctaClass = "h-11 gap-1.5 rounded-xl px-5 text-body-medium";
export default function LandingPage() {
return (
<div className="flex min-h-dvh flex-col">
<SiteHeader active="home" />
<main className="flex-1">
{/* --- Editorial Hero ------------------------------------------------ */}
<section className="paper-grain border-b border-line">
<div className="mx-auto grid max-w-7xl items-center gap-12 px-4 py-14 sm:px-6 lg:grid-cols-[1.1fr_0.9fr] lg:py-22">
<div>
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">
Weekly Meal &amp; Grocery Ledger · Saved Locally
</p>
<h1 className="mt-3 font-serif text-4xl leading-[1.08] font-semibold tracking-tight text-balance sm:text-5xl lg:text-[3.4rem]">
Cook a full week around{" "}
<span className="italic text-brand-300">what groceries actually cost at checkout.</span>
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-pretty text-ink-muted sm:text-lg">
Most meal planners divide a pinch of cumin into imaginary pennies. Plan my Meals prices whole store packages,
pairs recipes that share ingredients, and filters strictly to the appliances on your counter.
</p>
{/* Hero */}
<section className="mx-auto grid max-w-6xl items-center gap-12 px-4 py-16 sm:px-6 lg:grid-cols-2 lg:py-24">
<div>
<h1 className="text-display-4-semibold text-balance text-text-primary sm:text-display-2-semibold">
Good food all week, <span className="text-brand-300">tailored to your budget.</span>
</h1>
<p className="mt-5 max-w-lg text-headline-regular text-pretty text-ink-muted">
Plan My Meals takes the guesswork out of dinner. Build an entire week of meals around what’s already in your pantry, instantly price out missing ingredients, and explore over 400 high-quality recipes—100% free.
</p>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link
href="/plan"
className="trim-cap inline-flex min-h-12 items-center gap-2 rounded-lg bg-brand-600 px-6 py-3 font-semibold text-on-accent shadow-2xs transition-colors hover:bg-brand-500"
>
Start this week&apos;s plan
<ArrowRight className="size-4" strokeWidth={2} aria-hidden />
</Link>
<Link
href="/recipes"
className="trim-cap inline-flex min-h-12 items-center gap-2 rounded-lg border border-line-strong bg-surface px-5 py-3 font-medium text-ink transition-colors hover:bg-raised"
>
Browse {RECIPES.length} recipes
</Link>
</div>
{/* Architectural Ledger Metrics */}
<dl className="mt-10 grid max-w-xl grid-cols-2 gap-x-6 gap-y-4 border-t border-line pt-6 sm:grid-cols-4">
<div>
<dt className="text-xs text-ink-faint">Tested recipes</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{RECIPES.length}</dd>
</div>
<div>
<dt className="text-xs text-ink-faint">Global cuisines</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{CUISINES.length}</dd>
</div>
<div>
<dt className="text-xs text-ink-faint">Appliances</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{APPLIANCES.length}</dd>
</div>
<div>
<dt className="text-xs text-ink-faint">Priced items</dt>
<dd className="font-mono text-2xl font-semibold text-ink tabular-nums">{CATALOG.length}</dd>
</div>
</dl>
<div className="mt-8 flex flex-wrap items-center gap-3">
<Link href="/plan" className={cx(buttonStyles.base, buttonStyles.variant.primary, ctaClass)}>
Plan my week
<RiArrowRightLine className="size-5" aria-hidden />
</Link>
<Link href="/recipes" className={cx(buttonStyles.base, buttonStyles.variant.secondary, ctaClass)}>
Browse recipes
</Link>
</div>
<RotatingPreview sets={PREVIEW_SETS} />
</div>
<RotatingPreview sets={PREVIEW_SETS} />
</section>
{/* --- Interactive Package Overlap Proof ---------------------------- */}
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
<div className="mb-8 max-w-2xl">
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">Why the math matters</p>
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
Built for the checkout lane, not fictional per-gram math
</h2>
</div>
<OverlapExplorer dishes={OVERLAP_DATA.dishes} combos={OVERLAP_DATA.combos} />
{/* Three concise architectural pillars */}
<div className="mt-8 grid gap-px overflow-hidden rounded-xl border border-line bg-line lg:grid-cols-3">
<div className="bg-surface p-6">
<ReceiptText className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
<h3 className="mt-3 font-serif text-lg font-semibold">Whole-package register totals</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
You can&apos;t buy 0.3 lb of rice. Every ingredient rounds up to real store packages, showing both what you pay
today and what carries over into next week&apos;s pantry.
</p>
</div>
<div className="bg-surface p-6">
<Flame className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
<h3 className="mt-3 font-serif text-lg font-semibold">Appliance-honest filtering</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
Cooking in a dorm or studio without an oven? Uncheck it once and every suggested recipe is guaranteed to work on
the gear you actually own.
</p>
</div>
<div className="bg-surface p-6">
<SlidersHorizontal className="size-5 text-brand-300" strokeWidth={1.75} aria-hidden />
<h3 className="mt-3 font-serif text-lg font-semibold">Three-way pantry control</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-muted">
Mark any store item as <strong className="font-medium text-ink">Have it</strong> ($0 at checkout),{" "}
<strong className="font-medium text-ink">Use</strong>, or <strong className="font-medium text-ink">Avoid</strong>{" "}
across an entire aisle like Seafood or Dairy.
</p>
</div>
</div>
</section>
{/* --- Exposed-Border Workflow Ledger ------------------------------- */}
<section id="how-it-works" className="border-y border-line bg-surface">
<div className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-20">
<div className="max-w-2xl">
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">How it works</p>
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance">
Four steps from weekly budget to aisle-sorted list
</h2>
</div>
<ol className="mt-10 grid gap-8 border-t border-line pt-8 sm:grid-cols-2 lg:grid-cols-4">
{STEPS.map((step) => (
<li key={step.num} className="flex flex-col">
<span className="font-mono text-xs font-semibold tracking-widest text-brand-300">{step.num}</span>
<h3 className="mt-2 font-serif text-xl font-semibold">{step.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">{step.body}</p>
{/* How it works */}
<section id="how-it-works" className="bg-background-secondary-default">
<div className="mx-auto max-w-6xl px-4 py-16 sm:px-6 lg:py-20">
<h2 className="text-display-4-semibold text-balance text-text-primary">Three easy steps</h2>
<ol className="mt-8 grid gap-4 md:grid-cols-3">
{STEPS.map(({ icon: Icon, title, body }) => (
<li key={title} className="rounded-3xl border border-border-button-default bg-background-primary-default p-6">
<span className="grid size-10 place-items-center rounded-full bg-button-ghost-background text-button-ghost-foreground">
<Icon className="size-5" aria-hidden />
</span>
<h3 className="mt-4 text-title-3-semibold text-text-primary">{title}</h3>
<p className="mt-1.5 text-body-regular text-ink-muted">{body}</p>
</li>
))}
</ol>
</div>
</section>
{/* --- Editorial Cuisines & Cayden's Corner Spotlight --------------- */}
<section className="mx-auto max-w-7xl px-4 py-16 sm:px-6 lg:py-22">
<div className="grid gap-10 lg:grid-cols-[1.2fr_0.8fr] lg:items-center">
<div>
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">On the menu</p>
<h2 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance">
{CUISINES.length} cuisines, from quick weeknight bowls to slow Sunday braises
</h2>
<ul className="mt-6 flex flex-wrap gap-2">
{CUISINES.map((c) => (
<li key={c.id} className="rounded-full border border-line-strong bg-surface px-3.5 py-1.5 text-sm text-ink">
{c.label}
</li>
))}
</ul>
<div className="mt-6">
<Link
href="/recipes"
className="inline-flex items-center gap-1.5 font-medium text-brand-300 hover:underline"
>
Explore the full {RECIPES.length}-recipe index
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</div>
{/* What's cooking */}
<section className="mx-auto grid max-w-6xl gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1.2fr_0.8fr] lg:items-center lg:py-20">
<div>
<h2 className="text-display-4-semibold text-balance text-text-primary">
{RECIPES.length} recipes from {CUISINES.length} cuisines
</h2>
<p className="mt-3 max-w-lg text-headline-regular text-ink-muted">
Quick weeknight bowls, cozy weekend braises, and everything in between.
</p>
<ul className="mt-6 flex flex-wrap gap-2">
{CUISINES.map((c) => (
<li key={c.id} className="rounded-full bg-background-secondary-default px-3.5 py-1.5 text-body-medium text-text-primary">
{c.label}
</li>
))}
</ul>
</div>
{/* Heirloom Collection Card */}
<div className="paper-grain rounded-xl border border-brand-border bg-brand-950 p-6 sm:p-8">
<div className="inline-flex items-center gap-2 font-mono text-xs font-semibold tracking-wider text-brand-300 uppercase">
<BookHeart className="size-4" strokeWidth={1.75} aria-hidden />
Heirloom Collection
</div>
<h3 className="mt-2 font-serif text-2xl font-semibold tracking-tight">Cayden&apos;s Corner</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">
{FAMILY_COUNT} tried-and-true homemade family recipes—holiday sides, comfort mains, and classic bakes—scaled
automatically to your table and integrated right into the weekly planner.
</p>
<Link
href="/caydens-corner"
className="trim-cap mt-5 inline-flex min-h-10 items-center gap-2 rounded-lg border border-line-strong bg-surface px-4 py-2 text-sm font-semibold text-ink transition-colors hover:bg-raised"
>
Open family cookbook
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
<div className="rounded-3xl border border-brand-border bg-brand-950 p-6 sm:p-8">
<RiHeart3Line className="size-6 text-brand-300" aria-hidden />
<h3 className="mt-3 text-title-1-semibold text-text-primary">Cayden&apos;s Corner</h3>
<p className="mt-2 text-body-regular text-ink-muted">
{FAMILY_COUNT} favorite family recipes, from holiday sides to Sunday bakes, ready to drop into your week.
</p>
<Link href="/caydens-corner" className={cx(buttonStyles.base, buttonStyles.variant.secondary, "mt-5", ctaClass)}>
Open the family cookbook
<RiArrowRightLine className="size-5" aria-hidden />
</Link>
</div>
</section>
</main>
<footer className="border-t border-line bg-surface">
<div className="mx-auto flex max-w-7xl flex-wrap items-center justify-between gap-4 px-4 py-6 text-xs text-ink-faint sm:px-6">
<footer className="border-t border-separator-border">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-4 px-4 py-6 text-caption-1-regular text-ink-muted sm:px-6">
<Logo small />
<p>Estimates reflect average grocery prices as of October 2026 and vary by store and region.</p>
<p>Prices are estimates from October 2026 and vary by store.</p>
</div>
</footer>
</div>

View file

@ -1,4 +1,4 @@
import { ArrowLeft, BookHeart } from "lucide-react";
import { RiArrowLeftLine as ArrowLeft, RiHeart3Line as BookHeart } from "@remixicon/react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
@ -101,7 +101,7 @@ async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
</div>
{/* Cost ledger + Add to Plan action bar */}
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 rounded-xl border border-line bg-surface p-4 sm:px-5">
<div className="mt-6 flex flex-wrap items-center justify-between gap-4 rounded-3xl border border-border-button-default bg-surface p-4 sm:px-5">
<dl className="flex flex-wrap items-center gap-6 sm:gap-10">
<div>
<dt className="text-xs text-ink-faint">Cost eaten / serving</dt>
@ -120,7 +120,7 @@ async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
<AddToPlanControl recipeId={recipe.id} recipeTitle={recipe.title} />
</div>
<div className="mt-8 rounded-xl border border-line bg-surface p-5 sm:p-8">
<div className="mt-8 rounded-3xl border border-border-button-default bg-surface p-5 sm:p-8">
<RecipeServings recipe={recipe} />
</div>
@ -134,7 +134,7 @@ async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
<li key={r.id}>
<Link
href={`/recipes/${r.id}`}
className="group block rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
className="group block rounded-3xl border border-border-button-default bg-surface p-4 transition-colors hover:border-line-strong hover:bg-raised/60"
>
<span className="font-serif text-lg font-semibold text-ink group-hover:text-brand-300">{r.title}</span>
<span className="mt-1 block text-sm text-ink-muted">{r.description}</span>