This commit is contained in:
Cayden Schwechten 2026-10-31 20:44:29 -05:00
commit c19b910c36
80 changed files with 16134 additions and 0 deletions

41
.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

10
.idea/.gitignore generated vendored Normal file
View file

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

7
.idea/cat-activity.xml generated Normal file
View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CatActivitySettingProjectState">
<option name="enabled" value="true" />
<option name="firstInit" value="false" />
</component>
</project>

8
.idea/helpmeeat.iml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View file

@ -0,0 +1,13 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredErrors">
<list>
<option value="N806" />
</list>
</option>
</inspection_tool>
</profile>
</component>

6
.idea/jsLibraryMappings.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="JavaScriptLibraryMappings">
<includedPredefinedLibrary name="Node.js Core" />
</component>
</project>

8
.idea/modules.xml generated Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/helpmeeat.iml" filepath="$PROJECT_DIR$/.idea/helpmeeat.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

9
AGENTS.md Normal file
View file

@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->

144
README.md Normal file
View file

@ -0,0 +1,144 @@
# Plan my Meals: Budget Meal Planner
Plan 1–7 days of meals around a weekly grocery budget and the appliances you actually own.
Costs are estimated with **whole-package** pricing, so the total is what you'd pay at checkout,
not a misleading "per-serving" number.
## Run it
```bash
npm install
npm run dev # http://localhost:3000 (landing) → /plan (planner)
npm test # money math, state validation, meal slots (needs Bun: https://bun.sh)
```
Production: `npm run build && npm start`. Deploys to Vercel as-is (no env vars needed).
Requires Node 20.9+ (Next.js 16).
## How it works
```
app/
├── page.tsx Landing page (/): what the app is + how it works; links to /plan
├── plan/page.tsx Planner route (/plan); renders PlannerLoader
├── recipes/page.tsx Recipe library (/recipes): search + filters
├── caydens-corner/page.tsx Cayden's Corner (/caydens-corner): family recipes, by section
├── recipes/[id]/page.tsx One static page per recipe: ingredients, steps, servings scaler
├── actions.ts Server Action: generateMealPlanAction (validates input → planner)
├── components/
│ ├── RecipeDetail.tsx Ingredients (scaled) + instructions; shared by pages, picker and dialog
│ ├── RecipeDialog.tsx Opens when you click a planned meal: recipe + swap/remove
│ ├── RecipeBrowser.tsx Client-side search/filter grid for /recipes
│ ├── SiteHeader.tsx Header for landing + recipe pages
│ ├── RotatingPreview.tsx Landing-page hero card cycling through example meal sets
│ ├── PlannerLoader.tsx Loads the dashboard client-only (state lives in localStorage)
│ ├── PlannerDashboard.tsx Wires state → live summary → children
│ ├── Navbar.tsx Budget meter + save status
│ ├── ConfigPanel.tsx Budget, days, meals/day, people, appliances, cuisines, styles
│ ├── IngredientPanel.tsx Per-ingredient Avoid / Use / Have it switches
│ ├── MealGrid.tsx Day cards with slots (click to pick, drag to swap on desktop)
│ ├── MealSelectorModal.tsx Recipe picker sorted by *marginal* cost to your cart
│ ├── GroceryListDrawer.tsx Cost summary + aisle-grouped list (panel on desktop, sheet on mobile)
│ ├── BudgetMeter.tsx
│ └── Chip.tsx The on/off filter pill used by every filter row
├── lib/
│ ├── catalog.ts Store catalog (average grocery prices as of October 2026)
│ ├── recipes/ 421 recipes across 12 cuisines (see "Adding recipes" below)
│ │ ├── caydens-corner.ts Family recipes (44)
│ │ └── steps/ Cooking instructions, keyed by recipe id
│ ├── display.ts Ingredient lines with scaling + kitchen fractions (¾ cup)
│ ├── sanitize.ts Validates + migrates state (shared by Server Action and localStorage load)
│ ├── pricing.ts PriceProvider interface: the seam for real prices later
│ ├── units.ts Recipe units → package units (cups of rice → grams)
│ ├── grocery.ts Full grocery list, plus Cart: fast "what does this recipe add?"
│ ├── planner.ts Greedy, overlap-aware auto-fill
│ ├── plan.ts Slot helpers, labels, cuisines, "can I cook this?" blockers
│ └── usePlanState.ts Reducer + localStorage persistence
└── types/index.ts
```
**Where the math runs:** cost recalculation runs in the browser using the same pure functions as
the server, so every slider tweak updates instantly. Generation runs in a Server Action, so a
real price API key would stay server-side.
**Planner scoring** (lower wins, per slot, in day order):
```
marginal checkout cost + 0.25 × marginal eaten value + $1.50 × times already used + up to $2.50 jitter
```
Hard rules: only recipes your appliances can cook and that use no avoided ingredients; never the
same dish twice in one day; max 2 uses per week (relaxed only if nothing else fits, with a note
shown). Preference filters relax style → cuisine if they'd leave slots empty. Appliances and avoided
ingredients are never relaxed.
**Meal slots** are keyed `"<day>-<meal>"`, and meal numbers are fixed: 1 = Breakfast, 2 = Lunch,
3 = Dinner. "Meals per day" only decides which are visible (2 → Lunch + Dinner), the same way "Days"
hides days without deleting them. Clear and Shuffle only touch what's visible. Plans saved by older
versions (which counted from the first visible meal) are converted once on load.
## Ingredient preferences
Every catalog item has a three-way setting (Ingredients panel, or "Have it" on any grocery line):
| Setting | Effect |
| --- | --- |
| **Avoid** | Recipes using it are never auto-planned; already-planned ones are flagged |
| **Use** | Default |
| **Have it** | Stays on the shopping list but costs $0 (pantry staples start here) |
Each aisle also has an **Avoid all** button, e.g. the Seafood aisle for a fish/shellfish allergy.
## Seafood
`recipes/seafood.ts` holds 13 seafood dishes (plus Shrimp & Grits in the Southern file), all tagged
with the `seafood` style. Pick **🐟 Seafood** under Cooking style (or in the meal picker) to lean the
plan toward fish. All fish and shellfish live in one **Seafood** aisle on lists. Budget picks: canned
tuna/clams, pollock, tilapia, imitation crab. Splurges: shrimp, salmon.
## Swapping in real prices
1. Fetch prices server-side (in `actions.ts` or a route handler).
2. Build a `StoreItem[]` snapshot and wrap it: `new SnapshotPriceProvider("Live grocery prices", items)`.
3. Pass it via the `provider` option to `buildGroceryList` / the planner.
Item IDs in `recipes.ts` reference catalog IDs, so a real provider needs a mapping from those IDs to
store SKUs.
## Known limits (v1)
- Prices are estimates based on average grocery prices as of October 2026.
- Drag-to-swap is desktop-only (HTML5 drag & drop doesn't fire on touch); mobile uses tap-to-pick.
- Greedy planning is fast and predictable but not globally optimal.
- Leftover carryover isn't carried into *next* week's plan yet.
## Cayden's Corner
Family recipes: 4 appetizers & sides, 28 mains (one breakfast), 12 desserts.
Every recipe has the `caydens_corner` style, so it shows up under **🏠 Cayden's Corner** in the
library filters, the planner's Cooking style chips and the meal picker. Directions are in the
original wording (`recipes/steps/caydens-corner.ts`); small clarifications are in [brackets].
Sides and desserts are their own meal types: they're in the library and can be added to a slot
by hand, but auto-fill never picks them.
## Adding recipes
The big library in `app/lib/recipes/extra/` uses a compact one-entry-per-recipe format
(`recipes/dish.ts`). Ingredients are written like a shopping list:
```ts
{ title: "Beef Stroganoff", mins: 30, tools: ["stovetop"], styles: ["comfort"],
about: "Creamy mushroom gravy over egg noodles.",
items: "ground_beef 1 lb; egg_noodles 12 oz; mushrooms 8 oz; sour_cream 0.75 cup" },
```
- Each ingredient is `<catalog id> <amount> <unit>`; units: g, oz, lb, ml, tsp, tbsp, cup, each.
- These all throw when the app loads (and so fail `npm run build`): unknown catalog ids, units that
can't convert to how the item is sold, unknown formats, and duplicate ids.
- Cups/tbsp on an item sold by weight need `gramsPerMl` on that catalog item.
- Add the recipe's steps to the matching file in `recipes/steps/`, keyed by its id. The app refuses to
load if any recipe has no steps, or if steps exist for a recipe that doesn't.
- `id` defaults to a slug of the title. Set it explicitly before renaming a recipe, or saved plans
that used the old id will drop that meal.

27
app/actions.ts Normal file
View file

@ -0,0 +1,27 @@
"use server";
import type { FillMode, GeneratePlanInput, GeneratePlanResult } from "@/app/types";
import { generatePlan } from "@/app/lib/planner";
import { sanitizeState } from "@/app/lib/sanitize";
// ---------------------------------------------------------------------------
// Server Actions.
//
// Note on the split: live cost recalculation runs in the BROWSER (same pure
// functions from lib/), so every slider tweak updates instantly with no round
// trip. Generation runs HERE so that, once real prices exist, the price API
// key and fetch live server-side only.
//
// Server Actions are public HTTP endpoints, so input goes through the same
// sanitizer as saved state (lib/sanitize.ts) rather than being trusted.
// ---------------------------------------------------------------------------
export async function generateMealPlanAction(input: GeneratePlanInput): Promise<GeneratePlanResult> {
const state = sanitizeState(input?.state);
const fillMode: FillMode = input?.fillMode === "all" ? "all" : "empty";
const seed = Number.isFinite(input?.seed) ? Math.floor(input.seed) : Date.now();
// Real price lookups would go here: fetch → build a SnapshotPriceProvider
// → pass it through generatePlan/buildGroceryList.
return generatePlan(state, fillMode, seed);
}

108
app/caydens-corner/page.tsx Normal file
View file

@ -0,0 +1,108 @@
import { ArrowRight, BookHeart } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { ViewTransition } from "react";
import { ApplianceIcon } from "@/app/components/Icons";
import { SiteHeader } from "@/app/components/SiteHeader";
import { APPLIANCES } from "@/app/lib/plan";
import { RECIPES } from "@/app/lib/recipes";
import type { Recipe } from "@/app/types";
export const metadata: Metadata = {
title: "Cayden's Corner",
description: "Homemade family recipes: tried, tested and loved.",
};
const FAMILY = RECIPES.filter((r) => r.styles.includes("caydens_corner"));
const SECTIONS: { title: string; recipes: Recipe[] }[] = [
{ title: "Appetizers & Side Dishes", recipes: FAMILY.filter((r) => r.kind === "side") },
{ title: "Main Dishes", recipes: FAMILY.filter((r) => r.kind === "main" || r.kind === "breakfast") },
{ title: "Desserts & Bakes", recipes: FAMILY.filter((r) => r.kind === "dessert") },
].map((s) => ({ ...s, recipes: [...s.recipes].sort((a, b) => a.title.localeCompare(b.title)) }));
export default function CaydensCornerPage() {
return (
<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">
<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
</p>
<h1 className="mt-2 font-serif text-3xl font-semibold tracking-tight text-balance sm:text-5xl">
Family Recipes
</h1>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-ink-muted sm:text-lg">
{FAMILY.length} homemade family favorites—passed down, tested at the dinner table, and loved. Comfort classics,
holiday staples, and plenty of scratch-baked desserts.
</p>
<div className="mt-6 flex flex-wrap items-center gap-4 border-t border-brand-border/40 pt-5 text-sm text-ink-muted">
<p>
Written originally for a table of four with leftovers; the planner scales every ingredient automatically to your household.
</p>
<Link
href="/plan"
className="trim-cap ml-auto inline-flex min-h-10 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-4 py-2 text-sm font-semibold text-ink transition-colors hover:bg-raised"
>
Plan a family-recipe week
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</section>
{SECTIONS.map((section) => (
<section key={section.title} className="mt-12" aria-labelledby={`section-${section.title}`}>
<div className="flex items-baseline justify-between border-b border-line pb-2.5">
<h2 id={`section-${section.title}`} className="font-serif text-2xl font-semibold tracking-tight">
{section.title}
</h2>
<span className="font-mono text-xs text-ink-faint">{section.recipes.length} recipes</span>
</div>
<ul className="mt-5 grid gap-3.5 sm:grid-cols-2 lg:grid-cols-3">
{section.recipes.map((r) => {
const applianceNames =
r.requiredAppliances.map((a) => APPLIANCES.find((x) => x.id === a)?.label).join(", ") || "No cooking";
return (
<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"
>
<ViewTransition name={`recipe-title-${r.id}`}>
<span className="font-serif text-lg leading-snug font-semibold text-ink group-hover:text-brand-300">
{r.title}
</span>
</ViewTransition>
<span className="mt-1.5 line-clamp-2 text-sm leading-relaxed text-ink-muted">{r.description}</span>
<span className="mt-auto flex items-center justify-between border-t border-line pt-3 mt-4 text-xs text-ink-faint">
<span className="font-mono tabular-nums">
{r.prepTime >= 120 ? `${Math.round(r.prepTime / 60)} hr` : `${r.prepTime} min`} · serves {r.servings}
</span>
<span className="inline-flex items-center gap-1.5" title={applianceNames}>
{r.requiredAppliances.length > 0 ? (
<>
{r.requiredAppliances.map((a) => (
<ApplianceIcon key={a} id={a} className="size-3.5 text-ink-muted" />
))}
<span className="sr-only">Requires {applianceNames}</span>
</>
) : (
<span>No cooking</span>
)}
</span>
</span>
</Link>
</li>
);
})}
</ul>
</section>
))}
</main>
</div>
);
}

View file

@ -0,0 +1,146 @@
"use client";
import { ArrowRight, CalendarPlus, Check, X } from "lucide-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";
const STORAGE_KEY = "budget-meal-planner:v2";
function readPlan(): MealPlanState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
return raw ? sanitizeState(JSON.parse(raw)) : DEFAULT_STATE;
} catch {
return DEFAULT_STATE;
}
}
export function AddToPlanControl({ recipeId, recipeTitle }: { recipeId: string; recipeTitle: string }) {
const [open, setOpen] = useState(false);
const [plan, setPlan] = useState<MealPlanState>(DEFAULT_STATE);
const [addedLabel, setAddedLabel] = useState<string | null>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
return () => {
if (dialog.open) dialog.close();
};
}, [open]);
function assignSlot(day: number, meal: number) {
const latest = readPlan();
const key = slotKey(day, meal);
const updated: MealPlanState = {
...latest,
slots: { ...latest.slots, [key]: recipeId },
};
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
setPlan(updated);
setAddedLabel(`${DAY_NAMES[day]} · ${mealLabel(meal)}`);
setOpen(false);
} catch {
// Blocked storage
}
}
const meals = visibleMeals(plan.mealsPerDay);
return (
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
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>
{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">
<Check className="size-3.5 shrink-0" strokeWidth={2.5} aria-hidden />
Added to {addedLabel}
<Link href="/plan" className="ml-1 inline-flex items-center gap-1 font-semibold underline hover:opacity-85">
Open planner
<ArrowRight className="size-3" aria-hidden />
</Link>
</span>
)}
<dialog
ref={dialogRef}
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"
>
<div className="p-5 sm:p-6">
<div className="flex items-start justify-between gap-4 border-b border-line pb-3">
<div>
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">Assign slot</p>
<h2 id="add-to-plan-title" className="font-serif text-xl font-semibold">
Add &ldquo;{recipeTitle}&rdquo;
</h2>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close"
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted hover:bg-raised hover:text-ink"
>
<X className="size-5" aria-hidden />
</button>
</div>
<div className="mt-4 space-y-3">
{Array.from({ length: plan.daysCount }, (_, day) => (
<div key={day} className="rounded-lg border border-line bg-canvas p-3">
<p className="font-serif text-sm font-semibold text-ink">{DAY_NAMES[day]}</p>
<div className="mt-2 grid gap-2 sm:grid-cols-3">
{meals.map((meal) => {
const key = slotKey(day, meal);
const existingId = plan.slots[key];
const existingTitle = existingId ? RECIPES_BY_ID.get(existingId)?.title : null;
const isSame = existingId === recipeId;
return (
<button
key={key}
type="button"
onClick={() => assignSlot(day, meal)}
className={`min-h-12 rounded-lg border px-3 py-2 text-left text-xs transition-colors ${
isSame
? "border-status-ok-line bg-status-ok-bg text-status-ok"
: "border-line-strong bg-surface hover:border-brand-600 hover:bg-brand-950"
}`}
>
<span className="block font-mono text-[10px] tracking-wider text-ink-faint uppercase">
{mealLabel(meal)}
</span>
<span className="mt-0.5 block truncate font-medium text-ink">
{existingTitle ? existingTitle : "+ Empty slot"}
</span>
</button>
);
})}
</div>
</div>
))}
</div>
</div>
</dialog>
</div>
);
}

View file

@ -0,0 +1,40 @@
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) {
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" };
}
interface BudgetMeterProps {
spent: number;
budget: number;
compact?: boolean;
}
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>
<div
className="mt-1.5 h-2 w-full overflow-hidden rounded-full border border-line-strong/40 bg-raised"
role="meter"
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>
</div>
);
}

38
app/components/Chip.tsx Normal file
View file

@ -0,0 +1,38 @@
import { Check } from "lucide-react";
import type { ReactNode } from "react";
// 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.
interface ChipProps {
pressed: boolean;
onClick: () => void;
children: ReactNode;
/** "sm" for dense filter rows, "md" for settings panels. */
size?: "sm" | "md";
/** Dashed outline when off: tells a second filter row apart from the first. */
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}`}
>
{pressed && <Check className="size-3.5 shrink-0 text-brand-300" strokeWidth={2.5} aria-hidden />}
<span className="inline-flex items-center gap-1.5">{children}</span>
</button>
);
}

View file

@ -0,0 +1,272 @@
"use client";
import { ChevronDown, RotateCcw, Shuffle, SlidersHorizontal, Wand2 } from "lucide-react";
import { useState, type Dispatch } from "react";
import { APPLIANCES, CUISINES, STYLES, activeRecipeIds, mealLabel, visibleMeals } from "@/app/lib/plan";
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";
interface ConfigPanelProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
onGenerate: (mode: FillMode) => void;
onClear: () => void;
isGenerating: boolean;
}
const MEALS_OPTIONS = [
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3" },
] as const;
const PEOPLE_OPTIONS = [
{ value: 1, label: "1" },
{ value: 2, label: "2" },
{ value: 3, label: "3" },
{ value: 4, label: "4" },
{ value: 5, label: "5" },
{ value: 6, label: "6" },
] as const;
export function ConfigPanel({ state, dispatch, onGenerate, onClear, isGenerating }: ConfigPanelProps) {
const [filtersOpen, setFiltersOpen] = useState(false);
const filledCount = activeRecipeIds(state).length;
const mealNames = visibleMeals(state.mealsPerDay).map(mealLabel).join(" · ");
const activeFilterCount =
(APPLIANCES.length - state.selectedAppliances.length > 0 ? 1 : 0) +
state.preferredCuisines.length +
state.preferredStyles.length;
const filterSummary = [
`${state.selectedAppliances.length}/${APPLIANCES.length} appliances`,
state.preferredCuisines.length > 0 ? `${state.preferredCuisines.length} cuisines` : "Any cuisine",
state.preferredStyles.length > 0 ? `${state.preferredStyles.length} styles` : null,
]
.filter(Boolean)
.join(" · ");
return (
<section aria-labelledby="config-heading" className="rounded-xl border border-line bg-surface p-4 sm:p-5">
<h2 id="config-heading" className="sr-only">
Plan parameters
</h2>
{/* --- Compact Top Strip: Core Week Parameters ---------------------- */}
<div className="grid gap-x-6 gap-y-4 sm:grid-cols-2">
{/* Budget */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<label htmlFor="budget" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
Weekly budget
</label>
<span className="text-xs text-ink-faint">At checkout</span>
</div>
<div className="mt-1.5 flex min-h-9 items-center gap-2.5">
<input
id="budget-range"
type="range"
min={20}
max={300}
step={5}
value={state.targetBudget}
onChange={(e) => dispatch({ type: "setBudget", value: Number(e.target.value) })}
className="min-w-0 flex-1"
aria-label="Weekly budget slider"
/>
<div className="relative shrink-0">
<span className="pointer-events-none absolute top-1/2 left-2.5 -translate-y-1/2 font-mono text-xs text-ink-muted">$</span>
<input
id="budget"
type="number"
inputMode="numeric"
min={10}
max={1000}
value={state.targetBudget}
onChange={(e) => dispatch({ type: "setBudget", value: Math.max(0, Number(e.target.value) || 0) })}
className="min-h-9 w-22 rounded-lg border border-line-strong py-1 pr-2.5 pl-6 text-right font-mono text-sm font-semibold tabular-nums"
/>
</div>
</div>
</div>
{/* Days */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<label htmlFor="days" className="text-xs font-semibold tracking-wide text-ink-muted uppercase">
Days
</label>
<span className="font-mono text-xs font-semibold text-ink tabular-nums">
{state.daysCount} {state.daysCount === 1 ? "day" : "days"}
</span>
</div>
<div className="mt-1.5 flex min-h-9 items-center">
<input
id="days"
type="range"
min={1}
max={7}
value={state.daysCount}
onChange={(e) => dispatch({ type: "setDays", value: Number(e.target.value) })}
className="w-full"
/>
</div>
</div>
{/* Meals per day */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Meals / day</span>
<span className="truncate text-xs text-ink-faint">{mealNames}</span>
</div>
<div className="mt-1.5">
<RadioGroup
name="Meals per day"
options={MEALS_OPTIONS}
value={state.mealsPerDay}
onChange={(value) => dispatch({ type: "setMealsPerDay", value })}
fullWidth
/>
</div>
</div>
{/* Household size */}
<div className="min-w-0">
<div className="flex items-baseline justify-between gap-2">
<span className="shrink-0 text-xs font-semibold tracking-wide text-ink-muted uppercase">Household</span>
<span className="truncate text-xs text-ink-faint">Scales amounts</span>
</div>
<div className="mt-1.5">
<RadioGroup
name="People eating"
options={PEOPLE_OPTIONS}
value={state.householdSize}
onChange={(value) => dispatch({ type: "setHouseholdSize", value })}
fullWidth
/>
</div>
</div>
</div>
{/* --- 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 />
{isGenerating ? "Planning…" : "Auto-fill empty"}
</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 />
Shuffle plan
</button>
<button
type="button"
onClick={onClear}
disabled={isGenerating || filledCount === 0}
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink disabled:opacity-40"
>
<RotateCcw className="size-3.5 shrink-0" strokeWidth={1.75} aria-hidden />
Clear
</button>
</div>
<button
type="button"
aria-expanded={filtersOpen}
aria-controls="kitchen-filters-drawer"
onClick={() => setFiltersOpen((o) => !o)}
className={`trim-cap inline-flex min-h-10 items-center gap-2 rounded-lg border px-3.5 py-2 text-sm font-medium transition-colors ${
filtersOpen || activeFilterCount > 0
? "border-selected-line bg-selected text-ink"
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
}`}
>
<SlidersHorizontal className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
<span>Kitchen &amp; preferences</span>
<span className="hidden text-xs text-ink-faint sm:inline">({filterSummary})</span>
<ChevronDown
className={`size-4 shrink-0 text-ink-muted transition-transform ${filtersOpen ? "rotate-180" : ""}`}
aria-hidden
/>
</button>
</div>
{/* --- Collapsible Appliances, Cuisines & Styles -------------------- */}
{filtersOpen && (
<div id="kitchen-filters-drawer" className="mt-4 space-y-5 border-t border-line pt-4">
<fieldset>
<legend className="text-sm font-semibold text-ink">
Appliances you own <span className="font-normal text-ink-muted">· recipes needing anything else stay hidden</span>
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{APPLIANCES.map((a) => (
<Chip
key={a.id}
size="sm"
pressed={state.selectedAppliances.includes(a.id)}
onClick={() => dispatch({ type: "toggleAppliance", value: a.id })}
>
<ApplianceIcon id={a.id} className="size-3.5" />
{a.label}
</Chip>
))}
</div>
</fieldset>
<fieldset>
<legend className="text-sm font-semibold text-ink">
Preferred cuisines <span className="font-normal text-ink-muted">· none selected = any</span>
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{CUISINES.map((c) => (
<Chip
key={c.id}
size="sm"
pressed={state.preferredCuisines.includes(c.id)}
onClick={() => dispatch({ type: "toggleCuisine", value: c.id })}
>
{c.label}
</Chip>
))}
</div>
</fieldset>
<fieldset>
<legend className="text-sm font-semibold text-ink">
Cooking style <span className="font-normal text-ink-muted">· matches any selected</span>
</legend>
<div className="mt-2 flex flex-wrap gap-2">
{STYLES.map((s) => (
<Chip
key={s.id}
size="sm"
pressed={state.preferredStyles.includes(s.id)}
onClick={() => dispatch({ type: "toggleStyle", value: s.id })}
>
<StyleIcon id={s.id} className="size-3.5" />
{s.label}
</Chip>
))}
</div>
</fieldset>
</div>
)}
</section>
);
}

View file

@ -0,0 +1,221 @@
"use client";
import { Check, Copy, Repeat2, Square, SquareCheckBig, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { formatAmount, formatUsd } from "@/app/lib/units";
import type { Aisle, GroceryLine, GrocerySummary, ItemId } from "@/app/types";
import { BudgetMeter } from "./BudgetMeter";
interface PanelProps {
summary: GrocerySummary;
budget: number;
priceSourceLabel: string;
onToggleOnHand: (itemId: ItemId, onHand: boolean) => void;
}
export function GrocerySummaryPanel({ summary, budget, priceSourceLabel, onToggleOnHand }: PanelProps) {
const [copied, setCopied] = useState(false);
const byAisle = groupByAisle(summary.lines);
const remaining = budget - summary.checkoutTotal;
async function copyList() {
const text = summary.lines
.filter((l) => !l.onHand)
.map((l) => `☐ ${l.packages} × ${l.item.name} (${l.item.packageLabel})`)
.join("\n");
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard can be blocked; list remains visible on screen.
}
}
return (
<div>
{/* --- Tactile Market Receipt Header -------------------------------- */}
<div className="paper-grain rounded-xl border border-line 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">
<Stat label="At checkout" value={formatUsd(summary.checkoutTotal)} />
<Stat
label={remaining >= 0 ? "Left in budget" : "Over budget"}
value={formatUsd(Math.abs(remaining))}
tone={remaining >= 0 ? "text-status-ok" : "text-status-danger"}
/>
<Stat label="Eaten this week" value={formatUsd(summary.usedTotal)} />
<Stat label="Pantry carryover" value={formatUsd(summary.carryoverValue)} hint="Leftover package value kept" />
</dl>
{summary.sharedItemCount > 0 && (
<p className="mt-3 flex items-center gap-1.5 border-t border-line pt-2.5 text-xs font-medium text-status-ok">
<Repeat2 className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
{summary.sharedItemCount} store item{summary.sharedItemCount > 1 ? "s" : ""} shared across meals
</p>
)}
</div>
{/* --- Shopping List Header ----------------------------------------- */}
<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>
</div>
{summary.lines.length > 0 && (
<button
type="button"
onClick={copyList}
className="trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg border border-line-strong bg-surface px-3 py-1.5 text-xs font-semibold text-ink transition-colors hover:bg-raised"
>
{copied ? <Check className="size-3.5 text-status-ok" aria-hidden /> : <Copy className="size-3.5 text-ink-muted" aria-hidden />}
{copied ? "Copied" : "Copy list"}
</button>
)}
</div>
{summary.lines.length === 0 ? (
<p className="mt-6 rounded-lg border border-dashed border-line-strong p-6 text-center text-sm text-ink-muted">
Add meals to build your aisle-sorted grocery receipt.
</p>
) : (
<div className="mt-3 space-y-5">
{byAisle.map(([aisle, lines]) => (
<section key={aisle}>
<h4 className="border-b border-line pb-1 font-mono text-[11px] font-semibold tracking-wider text-ink-muted uppercase">
{aisle}
</h4>
<ul className="divide-y divide-line">
{lines.map((line) => (
<GroceryRow key={line.item.id} line={line} onToggleOnHand={onToggleOnHand} />
))}
</ul>
</section>
))}
</div>
)}
<p className="mt-6 text-xs text-ink-faint">{priceSourceLabel}. Actual store prices vary by location.</p>
</div>
);
}
function GroceryRow({ line, onToggleOnHand }: { line: GroceryLine; onToggleOnHand: PanelProps["onToggleOnHand"] }) {
const { item } = 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>
</span>
<span className="shrink-0 text-right font-mono text-sm font-medium tabular-nums">
{line.onHand ? "$0.00" : formatUsd(line.checkoutCost)}
</span>
</button>
</li>
);
}
function Stat({ label, value, hint, tone = "text-ink" }: { label: string; value: string; hint?: string; tone?: string }) {
return (
<div>
<dt className="text-xs text-ink-faint" title={hint}>
{label}
</dt>
<dd className={`mt-0.5 font-mono text-sm font-semibold tabular-nums ${tone}`}>{value}</dd>
</div>
);
}
function groupByAisle(lines: GroceryLine[]): [Aisle, GroceryLine[]][] {
const groups = new Map<Aisle, GroceryLine[]>();
for (const line of lines) {
const group = groups.get(line.item.aisle) ?? [];
group.push(line);
groups.set(line.item.aisle, group);
}
return [...groups];
}
// ---------------------------------------------------------------------------
// GroceryListDrawer: Native <dialog> mobile bottom sheet with focus trapping,
// scroll locking, and @starting-style spring transition.
// ---------------------------------------------------------------------------
interface DrawerProps extends PanelProps {
open: boolean;
onClose: () => void;
}
export function GroceryListDrawer({ open, onClose, ...panelProps }: DrawerProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
return () => {
if (dialog.open) dialog.close();
};
}, [open]);
return (
<dialog
ref={dialogRef}
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"
>
<div className="p-4 pb-8 sm:px-6">
<div className="mb-4 flex items-center justify-between border-b border-line pb-3">
<h2 id="grocery-drawer-title" className="font-serif text-xl font-semibold">
Market receipt &amp; list
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close grocery list"
className="grid size-10 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
<X className="size-5" aria-hidden />
</button>
</div>
<GrocerySummaryPanel {...panelProps} />
</div>
</dialog>
);
}

58
app/components/Icons.tsx Normal file
View file

@ -0,0 +1,58 @@
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 type { Appliance, CookingStyle } from "@/app/types";
export function ApplianceIcon({ id, className = "size-4", ...props }: { id: Appliance } & LucideProps) {
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
switch (id) {
case "stovetop":
return <Flame {...shared} />;
case "oven":
return <Heater {...shared} />;
case "air_fryer":
return <Wind {...shared} />;
case "slow_cooker":
return <Soup {...shared} />;
case "microwave":
return <Zap {...shared} />;
case "blender":
return <CupSoda {...shared} />;
case "rice_cooker":
return <CookingPot {...shared} />;
}
}
export function StyleIcon({ id, className = "size-3.5", ...props }: { id: CookingStyle } & LucideProps) {
const shared = { className, strokeWidth: 1.75, "aria-hidden": true as const, ...props };
switch (id) {
case "caydens_corner":
return <BookHeart {...shared} />;
case "seafood":
return <Fish {...shared} />;
case "quick":
return <Timer {...shared} />;
case "vegetarian":
return <Leaf {...shared} />;
case "healthy":
return <Salad {...shared} />;
case "meal_prep":
return <PackageCheck {...shared} />;
case "comfort":
return <Utensils {...shared} />;
}
}

View file

@ -0,0 +1,199 @@
"use client";
import { ChevronDown, Search, X } from "lucide-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";
import type { PlanAction } from "@/app/lib/usePlanState";
import type { Aisle, ItemId, ItemPreference, MealPlanState, StoreItem } from "@/app/types";
import { RadioGroup, type RadioOption } from "./RadioGroup";
interface IngredientPanelProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
}
type Filter = "all" | "avoid" | "have";
const RECIPE_COUNT: ReadonlyMap<ItemId, number> = (() => {
const counts = new Map<ItemId, number>();
for (const recipe of RECIPES) {
for (const id of new Set(recipe.ingredients.map((i) => i.itemId))) counts.set(id, (counts.get(id) ?? 0) + 1);
}
return counts;
})();
const FILTER_OPTIONS: readonly RadioOption<Filter>[] = [
{ value: "all", label: "All" },
{ value: "avoid", label: "Avoided" },
{ value: "have", label: "On hand" },
];
const PREF_OPTIONS: readonly RadioOption<ItemPreference>[] = [
{ value: "avoid", label: "Avoid", activeClass: "border border-status-danger-line bg-status-danger-bg text-status-danger font-semibold" },
{ value: "use", label: "Use", activeClass: "border border-selected-line/60 bg-surface text-ink font-semibold shadow-2xs" },
{ value: "have", label: "Have it", activeClass: "border border-status-ok-line bg-status-ok-bg text-status-ok font-semibold" },
];
export function IngredientPanel({ state, dispatch }: IngredientPanelProps) {
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<Filter>("all");
const preferenceOf = (id: ItemId): ItemPreference =>
state.avoidedItems.includes(id) ? "avoid" : state.onHandItems.includes(id) ? "have" : "use";
const setPreference = (itemIds: ItemId[], preference: ItemPreference) =>
dispatch({ type: "setItemPreference", itemIds, preference });
const needle = query.trim().toLowerCase();
const groups = useMemo(() => {
const visible = CATALOG.filter((item) => {
if (needle && !item.name.toLowerCase().includes(needle)) return false;
if (filter === "avoid") return state.avoidedItems.includes(item.id);
if (filter === "have") return state.onHandItems.includes(item.id);
return true;
});
const byAisle = new Map<Aisle, StoreItem[]>();
for (const item of [...visible].sort((a, b) => a.name.localeCompare(b.name))) {
byAisle.set(item.aisle, [...(byAisle.get(item.aisle) ?? []), item]);
}
return [...byAisle].sort(([a], [b]) => a.localeCompare(b));
}, [needle, filter, state.avoidedItems, state.onHandItems]);
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">
<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>
<h2 id="ingredients-heading" className="font-serif text-lg font-semibold">
Pantry &amp; ingredient exclusions
</h2>
<p className="text-xs text-ink-muted sm:text-sm">
<span className="font-mono font-medium text-ink">{state.onHandItems.length}</span> on hand ($0) ·{" "}
<span className="font-mono font-medium text-ink">{state.avoidedItems.length}</span> avoided
</p>
</div>
<span className="grid size-9 shrink-0 place-items-center rounded-lg border border-line bg-raised text-ink-muted transition-transform group-open:rotate-180">
<ChevronDown className="size-4" aria-hidden />
</span>
</summary>
<div className="border-t border-line p-4 sm:px-5">
{state.avoidedItems.length > 0 && (
<div className="mb-4 flex flex-wrap gap-1.5" aria-label="Avoided ingredients">
{state.avoidedItems.map((id) => (
<button
key={id}
type="button"
onClick={() => setPreference([id], "use")}
className="trim-cap inline-flex min-h-8 items-center gap-1 rounded-full border border-status-danger-line bg-status-danger-bg px-3 py-1 text-xs font-medium text-status-danger transition-colors hover:opacity-85"
aria-label={`Stop avoiding ${CATALOG_BY_ID.get(id)?.name}`}
>
{CATALOG_BY_ID.get(id)?.name}
<X className="size-3" aria-hidden />
</button>
))}
</div>
)}
{/* Search + filter */}
<div className="flex flex-wrap items-center gap-2.5">
<div className="relative w-full sm:w-auto sm:min-w-0 sm:flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
<input
type="search"
placeholder="Search store items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="min-h-10 w-full rounded-lg border border-line-strong py-1.5 pr-3 pl-9 text-sm"
aria-label="Search store items"
/>
</div>
<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">
<button
type="button"
onClick={() => setPreference(PANTRY_STAPLE_IDS, staplesAllOnHand ? "use" : "have")}
className="min-h-8 font-medium text-brand-300 hover:underline"
>
{staplesAllOnHand ? "Unmark pantry staples" : "Mark basic pantry staples as on hand"}
</button>
{(state.avoidedItems.length > 0 || state.onHandItems.length > 0) && (
<button
type="button"
onClick={() => setPreference([...state.avoidedItems, ...state.onHandItems], "use")}
className="min-h-8 text-ink-muted hover:text-ink hover:underline"
>
Reset all to &ldquo;Use&rdquo;
</button>
)}
</div>
<div className="mt-4 max-h-[28rem] space-y-5 overflow-y-auto pr-1">
{groups.length === 0 && <p className="py-8 text-center text-sm text-ink-muted">No store items match your search.</p>}
{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">
<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>
<ul className="grid gap-x-6 sm:grid-cols-2">
{items.map((item) => {
const current = preferenceOf(item.id);
const count = RECIPE_COUNT.get(item.id) ?? 0;
return (
<li key={item.id} className="flex min-w-0 items-center justify-between gap-3 border-b border-line py-2.5">
<div className="min-w-0">
<p className={`truncate text-sm font-medium ${current === "avoid" ? "text-ink-faint line-through" : "text-ink"}`}>
{item.name}
</p>
<p className="text-xs text-ink-faint">
Used in {count} recipe{count === 1 ? "" : "s"}
</p>
</div>
<RadioGroup
name={item.name}
options={PREF_OPTIONS}
value={current}
onChange={(val) => setPreference([item.id], val)}
size="xs"
/>
</li>
);
})}
</ul>
</section>
))}
</div>
</div>
</details>
</section>
);
}
function AisleBulkToggle({
itemIds,
avoidedItems,
onSet,
}: {
itemIds: ItemId[];
avoidedItems: ItemId[];
onSet: (itemIds: ItemId[], preference: ItemPreference) => void;
}) {
const allAvoided = itemIds.every((id) => avoidedItems.includes(id));
return (
<button
type="button"
onClick={() => onSet(itemIds, allAvoided ? "use" : "avoid")}
className="min-h-8 px-2 text-xs font-medium text-ink-muted transition-colors hover:text-status-danger"
>
{allAvoided ? "Allow aisle" : "Avoid entire aisle"}
</button>
);
}

231
app/components/MealGrid.tsx Normal file
View file

@ -0,0 +1,231 @@
"use client";
import { AlertTriangle, ArrowLeftRight, Plus, X } from "lucide-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";
import { formatUsd } from "@/app/lib/units";
import type { PlanAction } from "@/app/lib/usePlanState";
import type { MealPlanState } from "@/app/types";
import { ApplianceIcon } from "./Icons";
interface MealGridProps {
state: MealPlanState;
dispatch: Dispatch<PlanAction>;
mealCosts: Map<string, number>;
onOpenSlot: (key: string) => void;
onViewSlot: (key: string) => void;
onRemoveSlot: (key: string, recipeTitle: string) => void;
}
const applianceLabel = (id: string) => APPLIANCES.find((a) => a.id === id)?.label ?? id;
export function MealGrid({ state, dispatch, mealCosts, onOpenSlot, onViewSlot, onRemoveSlot }: MealGridProps) {
const [dragFrom, setDragFrom] = useState<string | null>(null);
const [dragOver, setDragOver] = useState<string | null>(null);
/** Mobile & keyboard slot-swap picker: holds the origin slot key currently choosing a destination. */
const [moveSourceKey, setMoveSourceKey] = useState<string | null>(null);
const allSlots = activeSlots(state);
const days = Array.from({ length: state.daysCount }, (_, day) => allSlots.filter((s) => s.dayIndex === day));
return (
<section aria-labelledby="grid-heading">
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
<h2 id="grid-heading" className="font-serif text-xl font-semibold tracking-tight">
Weekly menu
</h2>
<p className="text-xs text-ink-faint">
<span className="hidden md:inline">Drag meals or use the swap icon to rearrange · </span>
Tap any planned dish for its scaled recipe
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-3">
{days.map((slots, day) => {
const dayCost = slots.reduce((sum, s) => sum + (s.recipeId ? (mealCosts.get(s.recipeId) ?? 0) : 0), 0);
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">
{/* 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>
{hasAnyMeal && (
<span className="font-mono text-xs text-ink-muted tabular-nums">{formatUsd(dayCost)} eaten</span>
)}
</div>
<ul className="flex-1 divide-y divide-line">
{slots.map((slot) => {
const key = slotKey(slot.dayIndex, slot.mealNumber);
const recipe = slot.recipeId ? RECIPES_BY_ID.get(slot.recipeId) : undefined;
const blocked = recipe ? !isCookable(recipe, state) : false;
const label = mealLabel(slot.mealNumber);
const isDropTarget = dragOver === key && dragFrom !== key;
const isMovingThis = moveSourceKey === key;
return (
<li
key={key}
onDragOver={(e) => {
if (!dragFrom) return;
e.preventDefault();
setDragOver(key);
}}
onDragLeave={() => setDragOver((current) => (current === key ? null : current))}
onDrop={(e) => {
e.preventDefault();
if (dragFrom && dragFrom !== key) dispatch({ type: "swapSlots", from: dragFrom, to: key });
setDragFrom(null);
setDragOver(null);
}}
className={`transition-colors ${isDropTarget ? "bg-brand-950 ring-2 ring-inset ring-brand-600" : ""}`}
>
{recipe ? (
<div
draggable
onDragStart={() => setDragFrom(key)}
onDragEnd={() => {
setDragFrom(null);
setDragOver(null);
}}
className={`p-3.5 transition-colors ${
blocked ? "bg-status-warn-bg/65" : "hover:bg-raised/45"
}`}
>
<div className="flex items-start justify-between gap-2">
<button
type="button"
onClick={() => onViewSlot(key)}
className="min-w-0 flex-1 text-left"
aria-label={`View recipe for ${DAY_NAMES[day]} ${label}: ${recipe.title}`}
>
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
{label}
</span>
<span className="mt-0.5 block leading-snug font-semibold text-ink hover:text-brand-300">
{recipe.title}
</span>
<span className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-muted">
<span>{recipe.prepTime} min</span>
<span aria-hidden>·</span>
<span className="font-mono tabular-nums">{formatUsd(mealCosts.get(recipe.id) ?? 0)}/meal</span>
{recipe.requiredAppliances.length > 0 && (
<>
<span aria-hidden>·</span>
<span
className="inline-flex items-center gap-1 text-ink-faint"
title={`Requires: ${recipe.requiredAppliances.map(applianceLabel).join(", ")}`}
>
{recipe.requiredAppliances.map((a) => (
<ApplianceIcon key={a} id={a} className="size-3.5" />
))}
<span className="sr-only">
Requires {recipe.requiredAppliances.map(applianceLabel).join(", ")}
</span>
</span>
</>
)}
</span>
{blocked && (
<span className="mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-status-warn">
<AlertTriangle className="size-3.5 shrink-0" aria-hidden />
{describeBlockers(recipeBlockers(recipe, state))}
</span>
)}
</button>
{/* Non-overlapping 36x36px touch & keyboard action controls */}
<div className="flex shrink-0 items-center gap-0.5">
{allSlots.length > 1 && (
<button
type="button"
aria-expanded={isMovingThis}
onClick={() => setMoveSourceKey(isMovingThis ? null : key)}
aria-label={`Move or swap ${recipe.title}`}
title="Move or swap slot"
className={`grid size-9 place-items-center rounded-lg transition-colors ${
isMovingThis ? "bg-selected text-ink" : "text-ink-faint hover:bg-raised hover:text-ink"
}`}
>
<ArrowLeftRight className="size-4" strokeWidth={1.75} aria-hidden />
</button>
)}
<button
type="button"
onClick={() => {
if (isMovingThis) setMoveSourceKey(null);
onRemoveSlot(key, recipe.title);
}}
aria-label={`Remove ${recipe.title} from ${DAY_NAMES[day]} ${label}`}
title="Remove meal"
className="grid size-9 place-items-center rounded-lg text-ink-faint transition-colors hover:bg-status-danger-bg hover:text-status-danger"
>
<X className="size-4" strokeWidth={1.75} aria-hidden />
</button>
</div>
</div>
{/* Touch & keyboard accessible Swap/Move picker */}
{isMovingThis && (
<div className="mt-3 rounded-lg border border-line-strong bg-raised p-2.5">
<p className="text-xs font-medium text-ink-muted">Swap or move to another slot:</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{allSlots
.filter((target) => slotKey(target.dayIndex, target.mealNumber) !== key)
.map((target) => {
const targetKey = slotKey(target.dayIndex, target.mealNumber);
const occupant = target.recipeId ? RECIPES_BY_ID.get(target.recipeId)?.title : null;
return (
<button
key={targetKey}
type="button"
onClick={() => {
dispatch({ type: "swapSlots", from: key, to: targetKey });
setMoveSourceKey(null);
}}
className="trim-cap min-h-8 rounded-md border border-line-strong bg-surface px-2.5 py-1 text-xs font-medium text-ink transition-colors hover:border-brand-600 hover:bg-brand-950"
title={occupant ? `Swap with ${occupant}` : "Move to empty slot"}
>
{DAY_NAMES[target.dayIndex]} {mealLabel(target.mealNumber)}
{occupant ? " ⇄" : ""}
</button>
);
})}
</div>
</div>
)}
</div>
) : (
<button
type="button"
onClick={() => onOpenSlot(key)}
className="group flex min-h-16 w-full items-center justify-between px-4 py-3 text-left transition-colors hover:bg-raised/65"
>
<span>
<span className="block font-mono text-[11px] font-medium tracking-wider text-ink-faint uppercase">
{label}
</span>
<span className="mt-0.5 block text-sm font-medium text-ink-muted group-hover:text-ink">
Choose a meal
</span>
</span>
<span className="grid size-8 place-items-center rounded-lg border border-dashed border-line-strong text-ink-muted transition-colors group-hover:border-brand-600 group-hover:bg-brand-950 group-hover:text-brand-300">
<Plus className="size-4" strokeWidth={2} aria-hidden />
</span>
</button>
)}
</li>
);
})}
</ul>
</article>
);
})}
</div>
</section>
);
}

View file

@ -0,0 +1,297 @@
"use client";
import { ChevronDown, ChevronUp, Repeat2, Search, SlidersHorizontal, X } from "lucide-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";
import { RECIPES, RECIPES_BY_ID } from "@/app/lib/recipes";
import { formatUsd } from "@/app/lib/units";
import type { CookingStyle, Cuisine, MealPlanState, Recipe } from "@/app/types";
import { Chip } from "./Chip";
import { StyleIcon } from "./Icons";
import { RecipeDetail } from "./RecipeDetail";
interface MealSelectorModalProps {
slotKey: string | null;
state: MealPlanState;
onPick: (recipeId: string) => void;
onClose: () => void;
}
export function MealSelectorModal({ slotKey, state, onPick, onClose }: MealSelectorModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (slotKey && !dialog.open) dialog.showModal();
if (!slotKey && dialog.open) dialog.close();
return () => {
if (dialog.open) dialog.close();
};
}, [slotKey]);
return (
<dialog
ref={dialogRef}
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"
>
{slotKey && <SelectorContent key={slotKey} slotKey={slotKey} state={state} onPick={onPick} onClose={onClose} />}
</dialog>
);
}
interface Candidate {
recipe: Recipe;
addedCost: number;
reusedCount: number;
blockedReason: string;
}
function SelectorContent({
slotKey,
state,
onPick,
onClose,
}: {
slotKey: string;
state: MealPlanState;
onPick: (id: string) => void;
onClose: () => void;
}) {
const [dayIndex, mealNumber] = slotKey.split("-").map(Number);
const slotKind = mealKindFor(mealNumber);
const currentId = state.slots[slotKey];
const [query, setQuery] = useState("");
const [filtersOpen, setFiltersOpen] = useState(false);
const [cuisines, setCuisines] = useState<Cuisine[]>(state.preferredCuisines);
const [styles, setStyles] = useState<CookingStyle[]>(state.preferredStyles);
const [showAllKinds, setShowAllKinds] = useState(false);
const [showIncompatible, setShowIncompatible] = useState(false);
const [expandedId, setExpandedId] = useState<string | null>(null);
const candidates = useMemo<Candidate[]>(() => {
const others = activeRecipeIds(state);
if (currentId) others.splice(others.indexOf(currentId), 1);
const cart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems }, others);
const itemsInCart = new Set(others.flatMap((id) => RECIPES_BY_ID.get(id)?.ingredients.map((i) => i.itemId) ?? []));
return RECIPES.map((recipe) => ({
recipe,
addedCost: cart.marginal(recipe.id).checkout,
reusedCount: recipe.ingredients.filter((i) => itemsInCart.has(i.itemId)).length,
blockedReason: describeBlockers(recipeBlockers(recipe, state)),
})).sort((a, b) => a.addedCost - b.addedCost);
}, [state, currentId]);
const needle = query.trim().toLowerCase();
const visible = candidates.filter(
({ recipe, blockedReason }) =>
(showAllKinds || recipe.kind === slotKind) &&
(showIncompatible || blockedReason === "") &&
(cuisines.length === 0 || cuisines.includes(recipe.cuisine)) &&
(styles.length === 0 || recipe.styles.some((s) => styles.includes(s))) &&
(needle === "" || recipe.title.toLowerCase().includes(needle)),
);
const activeFilterCount = cuisines.length + styles.length + (showAllKinds ? 1 : 0) + (showIncompatible ? 1 : 0);
return (
<div className="flex max-h-[90dvh] flex-col">
{/* Compact sticky header so 80%+ of mobile viewport shows recipes */}
<div className="border-b border-line p-4 sm:px-5">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">
{DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)}
</p>
<h2 id="selector-title" className="font-serif text-xl font-semibold">
Choose a dish
</h2>
<p className="text-xs text-ink-muted">Sorted by marginal cost added to your current grocery cart</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
<X className="size-5" aria-hidden />
</button>
</div>
<div className="mt-3 flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
<input
type="search"
autoFocus
placeholder="Search recipes…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="min-h-10 w-full rounded-lg border border-line-strong py-2 pr-3 pl-9 text-sm"
/>
</div>
<button
type="button"
aria-expanded={filtersOpen}
onClick={() => setFiltersOpen((o) => !o)}
className={`trim-cap inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
filtersOpen || activeFilterCount > 0
? "border-selected-line bg-selected text-ink"
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
}`}
>
<SlidersHorizontal className="size-4" strokeWidth={1.75} aria-hidden />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="rounded-full bg-brand-600 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-on-accent">
{activeFilterCount}
</span>
)}
</button>
</div>
{filtersOpen && (
<div className="mt-3 max-h-52 space-y-3 overflow-y-auto border-t border-line pt-3">
<div className="flex flex-wrap gap-1.5">
{CUISINES.map((c) => (
<Chip key={c.id} size="sm" pressed={cuisines.includes(c.id)} onClick={() => setCuisines((list) => toggle(list, c.id))}>
{c.label}
</Chip>
))}
</div>
<div className="flex flex-wrap gap-1.5">
{STYLES.map((st) => (
<Chip key={st.id} size="sm" dashed pressed={styles.includes(st.id)} onClick={() => setStyles((list) => toggle(list, st.id))}>
<StyleIcon id={st.id} className="size-3.5" />
{st.label}
</Chip>
))}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-2 text-xs text-ink-muted sm:text-sm">
<label className="flex min-h-8 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={showAllKinds}
onChange={(e) => setShowAllKinds(e.target.checked)}
className="size-4 accent-brand-600"
/>
Include breakfasts, sides &amp; desserts
</label>
<label className="flex min-h-8 cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={showIncompatible}
onChange={(e) => setShowIncompatible(e.target.checked)}
className="size-4 accent-brand-600"
/>
Show blocked recipes (appliances / avoided items)
</label>
</div>
</div>
)}
</div>
{/* Results */}
<ul className="flex-1 space-y-2 overflow-y-auto p-4 sm:px-5">
{visible.length === 0 && (
<li className="py-10 text-center text-sm text-ink-muted">No recipes match these filters.</li>
)}
{visible.map(({ recipe, addedCost, reusedCount, blockedReason }) => {
const disabled = blockedReason !== "";
const expanded = expandedId === recipe.id;
return (
<li
key={recipe.id}
className={`rounded-xl border transition-colors ${
recipe.id === currentId ? "border-selected-line bg-raised" : "border-line bg-surface hover:border-line-strong"
}`}
>
<div className="flex items-start gap-3 p-3.5">
<button
type="button"
disabled={disabled}
onClick={() => onPick(recipe.id)}
className="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-55"
aria-label={`Choose ${recipe.title}`}
>
<p className="font-semibold text-ink">
{recipe.title}
{recipe.id === currentId && (
<span className="ml-2 rounded border border-selected-line bg-selected px-1.5 py-0.5 font-mono text-[11px] font-medium text-ink">
current
</span>
)}
</p>
<p className="mt-0.5 text-sm text-ink-muted">{recipe.description}</p>
<p className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
<span>{CUISINES.find((c) => c.id === recipe.cuisine)?.label}</span>
<span aria-hidden>·</span>
<span>{recipe.prepTime} min</span>
{recipe.requiredAppliances.length === 0 && (
<>
<span aria-hidden>·</span>
<span>No cooking</span>
</>
)}
{reusedCount > 0 && (
<span className="inline-flex items-center gap-1 font-medium text-status-ok">
<Repeat2 className="size-3" aria-hidden />
Reuses {reusedCount} item{reusedCount > 1 ? "s" : ""} in cart
</span>
)}
</p>
{disabled && <p className="mt-1.5 text-xs font-medium text-status-warn">{blockedReason}</p>}
</button>
<div className="flex shrink-0 flex-col items-end gap-1.5 text-right">
<div>
<p className={`font-mono text-sm font-semibold tabular-nums ${addedCost < 0.01 ? "text-status-ok" : "text-ink"}`}>
+{formatUsd(Math.max(0, addedCost))}
</p>
<p className="text-[11px] text-ink-faint">{addedCost < 0.01 ? "in cart" : "to cart"}</p>
</div>
<button
type="button"
onClick={() => setExpandedId(expanded ? null : recipe.id)}
aria-expanded={expanded}
aria-controls={`recipe-${recipe.id}`}
className="trim-cap inline-flex min-h-8 items-center gap-1 rounded-lg border border-line-strong bg-surface px-2.5 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
{expanded ? "Hide" : "Recipe"}
{expanded ? <ChevronUp className="size-3.5" aria-hidden /> : <ChevronDown className="size-3.5" aria-hidden />}
</button>
</div>
</div>
{expanded && (
<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"
>
Add to {DAY_NAMES[dayIndex]} {mealLabel(mealNumber).toLowerCase()}
</button>
)}
</div>
)}
</li>
);
})}
</ul>
</div>
);
}

36
app/components/Navbar.tsx Normal file
View file

@ -0,0 +1,36 @@
import type { SyncStatus } from "@/app/lib/usePlanState";
import { BudgetMeter } from "./BudgetMeter";
import { SiteHeader } from "./SiteHeader";
interface NavbarProps {
checkoutTotal: number;
budget: number;
syncStatus: SyncStatus;
isGenerating: boolean;
}
const SYNC_LABEL: Record<SyncStatus, { text: string; dot: string }> = {
saved: { text: "Saved on device", dot: "bg-status-ok" },
error: { text: "Storage blocked", dot: "bg-status-danger" },
};
export function Navbar({ checkoutTotal, budget, syncStatus, isGenerating }: NavbarProps) {
const sync = isGenerating ? { text: "Building plan…", dot: "bg-status-warn animate-pulse" } : SYNC_LABEL[syncStatus];
return (
<SiteHeader
active="plan"
subtitle={
<span className="inline-flex items-center gap-1.5 text-xs text-ink-muted" aria-live="polite" title={sync.text}>
<span className={`size-1.5 rounded-full ${sync.dot}`} />
<span className="sr-only sm:not-sr-only">{sync.text}</span>
</span>
}
rightSlot={
<div className="hidden w-60 border-l border-line pl-4 sm:block lg:w-68">
<BudgetMeter spent={checkoutTotal} budget={budget} />
</div>
}
/>
);
}

View file

@ -0,0 +1,149 @@
"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

@ -0,0 +1,246 @@
"use client";
import { AlertCircle, RotateCcw, ShoppingBag, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { generateMealPlanAction } from "@/app/actions";
import { Cart, buildGroceryList } from "@/app/lib/grocery";
import { activeRecipeIds } from "@/app/lib/plan";
import { averageGroceryProvider } from "@/app/lib/pricing";
import { RECIPES } from "@/app/lib/recipes";
import { usePlanState } from "@/app/lib/usePlanState";
import type { FillMode } from "@/app/types";
import { BudgetMeter } from "./BudgetMeter";
import { ConfigPanel } from "./ConfigPanel";
import { GroceryListDrawer, GrocerySummaryPanel } from "./GroceryListDrawer";
import { IngredientPanel } from "./IngredientPanel";
import { MealGrid } from "./MealGrid";
import { MealSelectorModal } from "./MealSelectorModal";
import { Navbar } from "./Navbar";
import { RecipeDialog } from "./RecipeDialog";
interface UndoSnapshot {
message: string;
previousSlots: Record<string, string>;
}
export default function PlannerDashboard() {
const { state, dispatch, syncStatus } = usePlanState();
const [isGenerating, startGenerating] = useTransition();
const [notes, setNotes] = useState<string[]>([]);
const [openSlot, setOpenSlot] = useState<string | null>(null);
const [viewSlot, setViewSlot] = useState<string | null>(null);
const [groceriesOpen, setGroceriesOpen] = useState(false);
const [undo, setUndo] = useState<UndoSnapshot | null>(null);
// Auto-dismiss undo toast after 6 seconds
useEffect(() => {
if (!undo) return;
const timer = window.setTimeout(() => setUndo(null), 6000);
return () => window.clearTimeout(timer);
}, [undo]);
const summary = useMemo(
() => buildGroceryList(activeRecipeIds(state), { householdSize: state.householdSize, onHandItems: state.onHandItems }),
[state],
);
const mealCosts = useMemo(() => {
const emptyCart = new Cart({ householdSize: state.householdSize, onHandItems: state.onHandItems });
return new Map(RECIPES.map((r) => [r.id, emptyCart.marginal(r.id).eaten]));
}, [state.householdSize, state.onHandItems]);
function handleGenerate(fillMode: FillMode) {
startGenerating(async () => {
try {
const result = await generateMealPlanAction({ state, fillMode, seed: Date.now() });
dispatch({ type: "replaceSlots", slots: result.slots });
setNotes(result.notes);
setUndo(null);
} catch {
setNotes(["Couldn't reach the planner. Check your connection and try again."]);
}
});
}
const handleRemoveSlot = useCallback(
(key: string, recipeTitle: string) => {
setUndo({ message: `Removed "${recipeTitle}"`, previousSlots: { ...state.slots } });
dispatch({ type: "setSlot", key, recipeId: null });
},
[dispatch, state.slots],
);
const handleClearWeek = useCallback(() => {
const count = activeRecipeIds(state).length;
if (count === 0) return;
setUndo({ message: `Cleared ${count} planned meal${count === 1 ? "" : "s"}`, previousSlots: { ...state.slots } });
dispatch({ type: "clearSlots" });
}, [dispatch, state]);
const handleUndo = useCallback(() => {
if (!undo) return;
dispatch({ type: "replaceSlots", slots: undo.previousSlots });
setUndo(null);
}, [dispatch, undo]);
const closeSelector = useCallback(() => setOpenSlot(null), []);
const closeRecipe = useCallback(() => setViewSlot(null), []);
const closeGroceries = useCallback(() => setGroceriesOpen(false), []);
const toggleOnHand = useCallback(
(itemId: string, onHand: boolean) => dispatch({ type: "setItemPreference", itemIds: [itemId], preference: onHand ? "have" : "use" }),
[dispatch],
);
const mealCount = activeRecipeIds(state).length;
return (
<>
<Navbar checkoutTotal={summary.checkoutTotal} budget={state.targetBudget} syncStatus={syncStatus} isGenerating={isGenerating} />
<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 */}
<div className="space-y-6 lg:col-span-2">
<ConfigPanel
state={state}
dispatch={dispatch}
onGenerate={handleGenerate}
onClear={handleClearWeek}
isGenerating={isGenerating}
/>
{notes.length > 0 && (
<div
role="status"
className="flex items-start justify-between gap-3 rounded-xl border border-status-warn-line bg-status-warn-bg p-3.5 text-sm text-status-warn"
>
<div className="flex items-start gap-2.5">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden />
<ul className="space-y-1">
{notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
</div>
<button
type="button"
onClick={() => setNotes([])}
aria-label="Dismiss notice"
className="grid size-8 shrink-0 place-items-center rounded-lg hover:bg-black/5"
>
<X className="size-4" aria-hidden />
</button>
</div>
)}
<div className={isGenerating ? "pointer-events-none opacity-60 transition-opacity" : "transition-opacity"}>
<MealGrid
state={state}
dispatch={dispatch}
mealCosts={mealCosts}
onOpenSlot={setOpenSlot}
onViewSlot={setViewSlot}
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">
<h2 className="mb-3 font-serif text-xl font-semibold">Market receipt</h2>
<GrocerySummaryPanel
summary={summary}
budget={state.targetBudget}
priceSourceLabel={averageGroceryProvider.label}
onToggleOnHand={toggleOnHand}
/>
</div>
</aside>
</div>
</main>
{/* Undo Toast for accidental meal removal or Clear */}
{undo && (
<div
role="status"
aria-live="polite"
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 />
Undo
</button>
<button
type="button"
onClick={() => setUndo(null)}
aria-label="Dismiss undo"
className="grid size-7 place-items-center rounded text-ink-faint hover:text-ink"
>
<X className="size-3.5" aria-hidden />
</button>
</div>
)}
{/* Mobile: sticky bottom bar */}
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line bg-surface/95 px-4 py-3 backdrop-blur-md lg:hidden">
<div className="mx-auto flex max-w-7xl items-center gap-4">
<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 />
Receipt ({summary.lines.length})
</button>
</div>
<p className="sr-only" aria-live="polite">
{mealCount} meals planned, estimated checkout {summary.checkoutTotal.toFixed(2)} dollars
</p>
</div>
<GroceryListDrawer
open={groceriesOpen}
onClose={closeGroceries}
summary={summary}
budget={state.targetBudget}
priceSourceLabel={averageGroceryProvider.label}
onToggleOnHand={toggleOnHand}
/>
<RecipeDialog
slotKey={viewSlot}
state={state}
onClose={closeRecipe}
onSwap={(key) => {
setViewSlot(null);
setOpenSlot(key);
}}
onRemove={(key, title) => {
handleRemoveSlot(key, title);
setViewSlot(null);
}}
/>
<MealSelectorModal
slotKey={openSlot}
state={state}
onClose={closeSelector}
onPick={(recipeId) => {
if (openSlot) dispatch({ type: "setSlot", key: openSlot, recipeId });
setOpenSlot(null);
}}
/>
</>
);
}

View file

@ -0,0 +1,31 @@
"use client";
import dynamic from "next/dynamic";
const PlannerDashboard = dynamic(() => import("./PlannerDashboard"), {
ssr: false,
loading: () => <DashboardSkeleton />,
});
export function PlannerLoader() {
return <PlannerDashboard />;
}
function DashboardSkeleton() {
return (
<div className="animate-pulse" 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" />
<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" />
))}
</div>
</div>
<div className="hidden h-[32rem] rounded-xl bg-raised lg:block" />
</div>
</div>
);
}

View file

@ -0,0 +1,95 @@
"use client";
import { useRef, type KeyboardEvent, type ReactNode } from "react";
export interface RadioOption<T extends string | number> {
value: T;
label: ReactNode;
activeClass?: string;
}
interface RadioGroupProps<T extends string | number> {
name: string;
options: readonly RadioOption<T>[];
value: T;
onChange: (value: T) => void;
size?: "xs" | "sm";
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";
return (
<div
role="radiogroup"
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`}
>
{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>
);
}

View file

@ -0,0 +1,231 @@
"use client";
import { BookHeart, Search, SlidersHorizontal } from "lucide-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";
import type { Appliance, CookingStyle, Cuisine, MealKind } from "@/app/types";
import { Chip } from "./Chip";
import { ApplianceIcon, StyleIcon } from "./Icons";
export interface RecipeSummary {
id: string;
title: string;
description: string;
cuisine: Cuisine;
kind: MealKind;
styles: CookingStyle[];
appliances: Appliance[];
prepTime: number;
ingredientText: string;
}
type KindFilter = "all" | MealKind;
type SortOrder = "az" | "quickest";
export function RecipeBrowser({ recipes }: { recipes: RecipeSummary[] }) {
const [query, setQuery] = useState("");
const [kind, setKind] = useState<KindFilter>("all");
const [cuisines, setCuisines] = useState<Cuisine[]>([]);
const [styles, setStyles] = useState<CookingStyle[]>([]);
const [kitchen, setKitchen] = useState<Appliance[]>([]);
const [sort, setSort] = useState<SortOrder>("az");
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const visible = useMemo(() => {
const needle = query.trim().toLowerCase();
return recipes
.filter((r) => kind === "all" || r.kind === kind)
.filter((r) => cuisines.length === 0 || cuisines.includes(r.cuisine))
.filter((r) => styles.length === 0 || r.styles.some((s) => styles.includes(s)))
.filter((r) => kitchen.length === 0 || r.appliances.every((a) => kitchen.includes(a)))
.filter((r) => !needle || r.title.toLowerCase().includes(needle) || r.ingredientText.includes(needle))
.sort((a, b) => (sort === "quickest" ? a.prepTime - b.prepTime : 0) || a.title.localeCompare(b.title));
}, [recipes, query, kind, cuisines, styles, kitchen, sort]);
const activeChipCount = (kind !== "all" ? 1 : 0) + cuisines.length + styles.length + kitchen.length;
const filtersActive = Boolean(query) || activeChipCount > 0;
const clearAll = () => {
setQuery("");
setKind("all");
setCuisines([]);
setStyles([]);
setKitchen([]);
};
return (
<div className="grid gap-8 lg:grid-cols-[18.5rem_1fr]">
{/* --- Search + Collapsible Mobile Filters / Sticky Desktop Sidebar --- */}
<aside className="space-y-4 lg:sticky lg:top-20 lg:self-start">
<div className="flex items-center gap-2">
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-ink-faint" aria-hidden />
<input
type="search"
placeholder="Search dishes or ingredients…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="min-h-10 w-full rounded-lg border border-line-strong py-2 pr-3 pl-9 text-sm"
aria-label="Search recipes or ingredients"
/>
</div>
<button
type="button"
aria-expanded={mobileFiltersOpen}
onClick={() => setMobileFiltersOpen((o) => !o)}
className={`trim-cap inline-flex min-h-10 shrink-0 items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium lg:hidden ${
mobileFiltersOpen || activeChipCount > 0
? "border-selected-line bg-selected text-ink"
: "border-line-strong bg-surface text-ink-muted"
}`}
>
<SlidersHorizontal className="size-4" aria-hidden />
Filters
{activeChipCount > 0 && (
<span className="rounded-full bg-brand-600 px-1.5 py-0.5 font-mono text-[11px] font-semibold text-on-accent">
{activeChipCount}
</span>
)}
</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`}>
<FilterGroup label="Course">
{(
[
["all", "All"],
["breakfast", "Breakfast"],
["main", "Lunch & dinner"],
["side", "Sides"],
["dessert", "Desserts"],
] as const
).map(([value, label]) => (
<Chip size="sm" key={value} pressed={kind === value} onClick={() => setKind(value)}>
{label}
</Chip>
))}
</FilterGroup>
<FilterGroup label="Cuisine">
{CUISINES.map((c) => (
<Chip size="sm" key={c.id} pressed={cuisines.includes(c.id)} onClick={() => setCuisines((l) => toggle(l, c.id))}>
{c.label}
</Chip>
))}
</FilterGroup>
<FilterGroup label="Style">
{STYLES.map((s) => (
<Chip size="sm" key={s.id} pressed={styles.includes(s.id)} onClick={() => setStyles((l) => toggle(l, s.id))}>
<StyleIcon id={s.id} className="size-3.5" />
{s.label}
</Chip>
))}
</FilterGroup>
<FilterGroup label="Available appliances" hint="Hides recipes needing anything else">
{APPLIANCES.map((a) => (
<Chip size="sm" key={a.id} pressed={kitchen.includes(a.id)} onClick={() => setKitchen((l) => toggle(l, a.id))}>
<ApplianceIcon id={a.id} className="size-3.5" />
{a.label}
</Chip>
))}
</FilterGroup>
</div>
</aside>
{/* --- Results -------------------------------------------------------- */}
<section aria-label="Recipes">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 border-b border-line pb-3">
<p className="text-sm text-ink-muted" aria-live="polite">
Showing <span className="font-mono font-semibold text-ink">{visible.length}</span> of{" "}
<span className="font-mono">{recipes.length}</span> recipes
{filtersActive && (
<button type="button" onClick={clearAll} className="ml-3 font-medium text-brand-300 hover:underline">
Reset filters
</button>
)}
</p>
<label className="flex items-center gap-2 text-sm text-ink-muted">
Sort
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOrder)}
className="min-h-9 rounded-lg border border-line-strong bg-surface px-2.5 py-1 text-sm font-medium text-ink"
>
<option value="az">A–Z</option>
<option value="quickest">Quickest first</option>
</select>
</label>
</div>
{visible.length === 0 ? (
<p className="rounded-xl border border-dashed border-line-strong p-10 text-center text-ink-muted">
No recipes match these filters. Try relaxing an appliance or cuisine filter.
</p>
) : (
<ul className="grid gap-3.5 sm:grid-cols-2 xl:grid-cols-3">
{visible.map((r) => {
const applianceNames = r.appliances.map((a) => APPLIANCES.find((x) => x.id === a)?.label).join(", ") || "No cooking";
return (
<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"
>
<span className="flex items-center gap-1.5 font-mono text-[11px] tracking-wide text-ink-faint uppercase">
{r.styles.includes("caydens_corner") && (
<BookHeart className="size-3.5 shrink-0 text-brand-300" strokeWidth={2} aria-hidden />
)}
<span>
{CUISINES.find((c) => c.id === r.cuisine)?.label} · {MEAL_KIND_LABELS[r.kind]}
</span>
</span>
<ViewTransition name={`recipe-title-${r.id}`}>
<span className="mt-1 font-serif text-lg leading-snug font-semibold text-ink group-hover:text-brand-300">
{r.title}
</span>
</ViewTransition>
<span className="mt-1.5 line-clamp-2 text-sm leading-relaxed text-ink-muted">{r.description}</span>
<span className="mt-auto flex items-center justify-between border-t border-line pt-3 mt-4 text-xs text-ink-faint">
<span className="font-mono tabular-nums">{r.prepTime} min</span>
<span className="inline-flex items-center gap-1.5" title={applianceNames}>
{r.appliances.length > 0 ? (
<>
{r.appliances.map((a) => (
<ApplianceIcon key={a} id={a} className="size-3.5 text-ink-muted" />
))}
<span className="sr-only">Requires {applianceNames}</span>
</>
) : (
<span>No cooking</span>
)}
</span>
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
</div>
);
}
function FilterGroup({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
return (
<fieldset>
<legend className="text-xs font-semibold tracking-wider text-ink-muted uppercase">
{label} {hint && <span className="font-sans font-normal tracking-normal text-ink-faint normal-case">· {hint}</span>}
</legend>
<div className="mt-2 flex flex-wrap gap-1.5">{children}</div>
</fieldset>
);
}

View file

@ -0,0 +1,229 @@
"use client";
import { Check, Lightbulb } from "lucide-react";
import { useState } from "react";
import { ingredientLines } from "@/app/lib/display";
import { APPLIANCES, CUISINES, MEAL_KIND_LABELS, STYLES } from "@/app/lib/plan";
import type { Recipe } from "@/app/types";
import { ApplianceIcon, StyleIcon } from "./Icons";
interface RecipeDetailProps {
recipe: Recipe;
/** People to scale ingredient amounts for. */
servings: number;
/** Hide the cuisine/time/appliance chips when surrounding UI already shows them. */
showMeta?: boolean;
/** Smaller type for use inside lists. */
compact?: boolean;
/** Allow tapping ingredients & steps to check them off while cooking. */
interactive?: boolean;
}
export function RecipeDetail({
recipe,
servings,
showMeta = true,
compact = false,
interactive = false,
}: RecipeDetailProps) {
const lines = ingredientLines(recipe, servings);
const text = compact ? "text-sm" : "text-base";
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(() => new Set());
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(() => new Set());
function toggleSet(setter: React.Dispatch<React.SetStateAction<Set<number>>>, index: number) {
setter((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
}
return (
<div className={compact ? "space-y-4" : "space-y-8"}>
{showMeta && <RecipeMeta recipe={recipe} />}
<div className={compact ? "grid gap-5 md:grid-cols-[2fr_3fr]" : "grid gap-8 md:grid-cols-[2fr_3fr]"}>
{/* --- Ingredients ------------------------------------------------- */}
<section>
<div className="flex items-baseline justify-between gap-2">
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>
Ingredients{" "}
<span className="font-sans text-sm font-normal text-ink-muted">
· {servings} serving{servings === 1 ? "" : "s"}
</span>
</h3>
{interactive && checkedIngredients.size > 0 && (
<button
type="button"
onClick={() => setCheckedIngredients(new Set())}
className="text-xs font-medium text-brand-300 hover:underline"
>
Reset
</button>
)}
</div>
<ul className={`mt-3 divide-y divide-line ${text}`}>
{lines.map((line, i) => {
const done = interactive && checkedIngredients.has(i);
const content = (
<>
<span className="w-20 shrink-0 text-right font-mono text-sm font-semibold text-ink tabular-nums">
{line.quantity} {line.unit}
</span>
<span className={done ? "text-ink-faint line-through" : "text-ink"}>
{line.name}
{line.detail && <span className="text-ink-muted"> ({line.detail})</span>}
</span>
</>
);
return (
<li key={i}>
{interactive ? (
<button
type="button"
aria-pressed={done}
onClick={() => toggleSet(setCheckedIngredients, i)}
className="flex min-h-10 w-full items-baseline gap-3 py-2 text-left transition-colors hover:bg-raised/50"
>
{content}
</button>
) : (
<div className="flex items-baseline gap-3 py-1.5">{content}</div>
)}
</li>
);
})}
</ul>
</section>
{/* --- Instructions ------------------------------------------------ */}
<section>
<div className="flex items-baseline justify-between gap-2">
<h3 className={`font-serif font-semibold ${compact ? "text-base" : "text-xl"}`}>Instructions</h3>
{interactive && checkedSteps.size > 0 && (
<button
type="button"
onClick={() => setCheckedSteps(new Set())}
className="text-xs font-medium text-brand-300 hover:underline"
>
Reset steps
</button>
)}
</div>
{servings !== recipe.servings && (
<p className="mt-2.5 rounded-lg border border-line-strong/50 bg-raised px-3 py-2 text-xs text-ink-muted">
Ingredient quantities on the left are scaled to <strong className="font-semibold text-ink">{servings}</strong>.
Step prose is written for the original {recipe.servings}-serving batch—scale pan sizes or liquid mentions the same way.
</p>
)}
<ol className={`mt-3 space-y-3 ${text}`}>
{recipe.steps.map((step, i) => {
const done = interactive && checkedSteps.has(i);
const badge = (
<span
className={`grid size-6 shrink-0 place-items-center rounded-full font-mono text-xs font-semibold transition-colors ${
done
? "bg-status-ok text-canvas"
: "border border-selected-line/50 bg-selected text-ink"
}`}
>
{done ? <Check className="size-3.5" strokeWidth={2.5} aria-hidden /> : i + 1}
</span>
);
return (
<li key={i}>
{interactive ? (
<button
type="button"
aria-pressed={done}
onClick={() => toggleSet(setCheckedSteps, i)}
className="flex w-full items-start gap-3 rounded-lg p-1.5 -m-1.5 text-left transition-colors hover:bg-raised/50"
>
{badge}
<span className={`pt-0.5 leading-relaxed ${done ? "text-ink-faint line-through" : "text-ink"}`}>{step}</span>
</button>
) : (
<div className="flex items-start gap-3">
{badge}
<span className="pt-0.5 leading-relaxed">{step}</span>
</div>
)}
</li>
);
})}
</ol>
{recipe.tip && (
<div className="mt-5 flex items-start gap-2.5 rounded-lg border border-brand-border bg-brand-950 px-3.5 py-3 text-sm text-ink">
<Lightbulb className="mt-0.5 size-4 shrink-0 text-brand-300" strokeWidth={2} aria-hidden />
<p>
<span className="font-semibold text-brand-300">Cook&apos;s note:</span> {recipe.tip}
</p>
</div>
)}
</section>
</div>
</div>
);
}
export function RecipeMeta({ recipe }: { recipe: Recipe }) {
const cuisineLabel = CUISINES.find((c) => c.id === recipe.cuisine)?.label;
return (
<ul className="flex flex-wrap gap-1.5">
{cuisineLabel && (
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs font-medium text-ink">
{cuisineLabel}
</li>
)}
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
{MEAL_KIND_LABELS[recipe.kind]}
</li>
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 font-mono text-xs text-ink-muted">
{recipe.prepTime} min
</li>
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">
Serves {recipe.servings}
</li>
{recipe.requiredAppliances.length === 0 ? (
<li className="rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted">No cooking</li>
) : (
recipe.requiredAppliances.map((a) => {
const label = APPLIANCES.find((x) => x.id === a)?.label ?? a;
return (
<li
key={a}
className="inline-flex items-center gap-1.5 rounded-full border border-line-strong bg-raised px-3 py-1 text-xs text-ink-muted"
>
<ApplianceIcon id={a} className="size-3.5 text-ink" />
{label}
</li>
);
})
)}
{recipe.styles.map((s) => {
const label = STYLES.find((x) => x.id === s)?.label;
if (!label) return null;
return (
<li
key={s}
className="inline-flex items-center gap-1.5 rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-muted"
>
<StyleIcon id={s} className="size-3.5 text-brand-300" />
{label}
</li>
);
})}
</ul>
);
}

View file

@ -0,0 +1,111 @@
"use client";
import { AlertTriangle, ArrowRight, ArrowRightLeft, Trash2, X } from "lucide-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";
interface RecipeDialogProps {
slotKey: string | null;
state: MealPlanState;
onClose: () => void;
onSwap: (slotKey: string) => void;
onRemove: (slotKey: string, recipeTitle: string) => void;
}
export function RecipeDialog({ slotKey, state, onClose, onSwap, onRemove }: RecipeDialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const recipe = slotKey ? RECIPES_BY_ID.get(state.slots[slotKey]) : undefined;
const open = Boolean(slotKey && recipe);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) dialog.showModal();
if (!open && dialog.open) dialog.close();
return () => {
if (dialog.open) dialog.close();
};
}, [open]);
const [dayIndex, mealNumber] = (slotKey ?? "0-1").split("-").map(Number);
return (
<dialog
ref={dialogRef}
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"
>
{open && recipe && slotKey && (
<div className="p-5 sm:p-7">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-mono text-xs tracking-wider text-ink-faint uppercase">
{DAY_NAMES[dayIndex]} · {mealLabel(mealNumber)}
</p>
<h2 id="recipe-dialog-title" className="mt-0.5 font-serif text-2xl font-semibold tracking-tight sm:text-3xl">
{recipe.title}
</h2>
<p className="mt-1.5 text-ink-muted">{recipe.description}</p>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="grid size-10 shrink-0 place-items-center rounded-lg text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
<X className="size-5" aria-hidden />
</button>
</div>
<div className="mt-4">
<RecipeMeta recipe={recipe} />
</div>
{!isCookable(recipe, state) && (
<p className="mt-4 flex items-center gap-2 rounded-lg border border-status-warn-line bg-status-warn-bg px-3.5 py-2.5 text-sm text-status-warn">
<AlertTriangle className="size-4 shrink-0" aria-hidden />
<span>{describeBlockers(recipeBlockers(recipe, state))}. Consider swapping this meal.</span>
</p>
)}
<div className="mt-6">
<RecipeDetail recipe={recipe} servings={state.householdSize} showMeta={false} interactive />
</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 />
Replace dish
</button>
<button
type="button"
onClick={() => onRemove(slotKey, recipe.title)}
className="trim-cap inline-flex min-h-10 items-center gap-1.5 rounded-lg px-3.5 py-2 text-sm font-medium text-ink-muted transition-colors hover:bg-status-danger-bg hover:text-status-danger"
>
<Trash2 className="size-4" aria-hidden />
Remove from plan
</button>
<Link
href={`/recipes/${recipe.id}`}
onClick={onClose}
className="ml-auto inline-flex min-h-10 items-center gap-1.5 text-sm font-medium text-brand-300 hover:underline"
>
Full recipe &amp; cook mode
<ArrowRight className="size-4" aria-hidden />
</Link>
</div>
</div>
)}
</dialog>
);
}

View file

@ -0,0 +1,101 @@
"use client";
import { Flame, Minus, Plus } from "lucide-react";
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import type { Recipe } from "@/app/types";
import { RecipeDetail } from "./RecipeDetail";
const noopSubscribe = () => () => {};
/** RecipeDetail with an accessible servings stepper + Screen Wake Lock ("Cook mode") toggle. */
export function RecipeServings({ recipe }: { recipe: Recipe }) {
const [servings, setServings] = useState(recipe.servings);
const [cookMode, setCookMode] = useState(false);
const wakeSupported = useSyncExternalStore(noopSubscribe, () => "wakeLock" in navigator, () => false);
const wakeLockRef = useRef<WakeLockSentinel | null>(null);
useEffect(() => {
if (!cookMode || !wakeSupported) {
wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
return;
}
let cancelled = false;
async function acquire() {
try {
const sentinel = await navigator.wakeLock.request("screen");
if (cancelled) {
await sentinel.release();
return;
}
wakeLockRef.current = sentinel;
} catch {
// Battery saver or background tab can refuse wakeLock; fail gracefully.
}
}
acquire();
const onVisibility = () => {
if (document.visibilityState === "visible" && cookMode) acquire();
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
cancelled = true;
document.removeEventListener("visibilitychange", onVisibility);
wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
};
}, [cookMode, wakeSupported]);
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3 border-b border-line pb-4">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-ink-muted">Scale servings</span>
<div className="inline-flex items-center rounded-lg border border-line-strong bg-raised p-0.5">
<button
type="button"
onClick={() => setServings((s) => Math.max(1, s - 1))}
className="grid size-9 place-items-center rounded-md text-ink-muted transition-colors hover:bg-surface hover:text-ink"
aria-label="Fewer servings"
>
<Minus className="size-4" aria-hidden />
</button>
<span className="min-w-9 text-center font-mono text-sm font-semibold tabular-nums" aria-live="polite">
{servings}
</span>
<button
type="button"
onClick={() => setServings((s) => Math.min(12, s + 1))}
className="grid size-9 place-items-center rounded-md text-ink-muted transition-colors hover:bg-surface hover:text-ink"
aria-label="More servings"
>
<Plus className="size-4" aria-hidden />
</button>
</div>
<span className="hidden text-xs text-ink-faint sm:inline">Tap any ingredient or step below to check it off</span>
</div>
{wakeSupported && (
<button
type="button"
aria-pressed={cookMode}
onClick={() => setCookMode((c) => !c)}
className={`trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-semibold transition-colors ${
cookMode
? "border-status-ok-line bg-status-ok-bg text-status-ok"
: "border-line-strong bg-surface text-ink-muted hover:bg-raised hover:text-ink"
}`}
>
<Flame className="size-3.5" aria-hidden />
{cookMode ? "Screen kept awake" : "Keep screen awake"}
</button>
)}
</div>
<RecipeDetail recipe={recipe} servings={servings} showMeta={false} interactive />
</div>
);
}

View file

@ -0,0 +1,123 @@
"use client";
import { Pause, Play, Repeat2, ShoppingBag } from "lucide-react";
import { useEffect, useState } from "react";
export interface PreviewSet {
theme: string;
blurb: string;
meals: { slot: string; title: string; cost: string }[];
total: string;
sharedItems: number;
itemCount: number;
}
const INTERVAL_MS = 5000;
export function RotatingPreview({ sets }: { sets: PreviewSet[] }) {
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const [hovering, setHovering] = useState(false);
const running = !paused && !hovering;
useEffect(() => {
if (!running || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const timer = window.setInterval(() => setIndex((i) => (i + 1) % sets.length), INTERVAL_MS);
return () => window.clearInterval(timer);
}, [running, index, sets.length]);
const set = sets[index];
return (
<section
aria-label="Example meal plans"
className="relative"
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
onFocus={() => setHovering(true)}
onBlur={() => setHovering(false)}
>
{/* Architectural editorial frame — crisp 1px border & tactile offset shadow instead of blurred glow blobs */}
<div className="rounded-xl border border-line-strong bg-surface shadow-[6px_6px_0_0_var(--color-line)]">
{/* Top progress rule */}
<div className="h-1 overflow-hidden rounded-t-xl bg-raised">
<div
key={index}
className="h-full origin-left animate-preview-timer bg-brand-600"
style={{ animationDuration: `${INTERVAL_MS}ms`, animationPlayState: running ? "running" : "paused" }}
/>
</div>
<div className="p-5 sm:p-6">
<div key={set.theme} className="animate-fade-in">
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
<div>
<p className="font-mono text-[11px] tracking-wider text-ink-faint uppercase">Sample menu · 2 people</p>
<h2 className="mt-0.5 font-serif text-xl font-semibold tracking-tight text-ink">{set.theme}</h2>
</div>
<span className="rounded-md border border-line bg-raised px-2.5 py-1 text-xs font-medium text-ink-muted">
{set.blurb}
</span>
</div>
<ul className="divide-y divide-line">
{set.meals.map((m) => (
<li key={m.slot} className="flex items-baseline justify-between gap-4 py-3">
<div className="min-w-0">
<p className="font-mono text-[11px] tracking-wide text-ink-faint uppercase">{m.slot}</p>
<p className="truncate font-medium text-ink">{m.title}</p>
</div>
<p className="shrink-0 font-mono text-xs text-ink-muted tabular-nums">{m.cost}/meal</p>
</li>
))}
</ul>
{/* Receipt tear-off footer */}
<div className="receipt-rule mt-2 pt-3.5">
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg bg-raised px-3.5 py-2.5 text-xs">
<span className="inline-flex items-center gap-1.5 font-medium text-status-ok">
<Repeat2 className="size-3.5 shrink-0" strokeWidth={2} aria-hidden />
{set.sharedItems} shared store items
</span>
<span className="inline-flex items-center gap-1.5 font-mono font-semibold text-ink tabular-nums">
<ShoppingBag className="size-3.5 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden />
{set.itemCount} pkgs · {set.total}
</span>
</div>
</div>
</div>
{/* Accessible 40x40px pagination & playback controls */}
<div className="mt-3 flex items-center justify-between border-t border-line pt-2">
<div className="flex items-center">
{sets.map((s, i) => (
<button
key={s.theme}
type="button"
onClick={() => setIndex(i)}
aria-label={`Show example: ${s.theme}`}
aria-current={i === index}
className="grid size-9 place-items-center rounded-md hover:bg-raised"
>
<span
className={`block h-2 rounded-full transition-all ${
i === index ? "w-5 bg-brand-600" : "w-2 bg-line-strong"
}`}
/>
</button>
))}
</div>
<button
type="button"
onClick={() => setPaused((p) => !p)}
className="trim-cap inline-flex min-h-9 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-ink-muted transition-colors hover:bg-raised hover:text-ink"
>
{paused ? <Play className="size-3.5" aria-hidden /> : <Pause className="size-3.5" aria-hidden />}
{paused ? "Play" : "Pause"}
</button>
</div>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,109 @@
import { BookHeart, BookOpen, CalendarRange, UtensilsCrossed } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react";
import { ThemeToggle } from "./ThemeToggle";
export type ActiveNav = "home" | "recipes" | "caydens-corner" | "plan";
export function Logo({ small = false, subtitle }: { small?: boolean; subtitle?: ReactNode }) {
return (
<div className="flex shrink-0 items-center gap-2.5">
<Link
href="/"
aria-label="Plan my Meals home"
className={`grid shrink-0 place-items-center rounded-lg border border-brand-border bg-brand-950 text-brand-300 transition-colors hover:bg-selected ${
small ? "size-7" : "size-9"
}`}
>
<UtensilsCrossed className={small ? "size-3.5" : "size-4"} strokeWidth={2} aria-hidden />
</Link>
<div className="min-w-0">
<Link
href="/"
className={`block font-serif leading-tight font-semibold tracking-tight whitespace-nowrap hover:text-brand-300 ${
small ? "text-sm text-ink-muted" : "text-lg text-ink"
}`}
>
Plan my Meals
</Link>
<div className="flex items-center gap-1.5 text-xs leading-tight text-ink-faint whitespace-nowrap">
<span>
by{" "}
<a
href="https://caydens.dev"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-ink-muted transition-colors hover:text-brand-300 hover:underline"
>
Cayden Codes
</a>
</span>
{subtitle && (
<>
<span aria-hidden>·</span>
{subtitle}
</>
)}
</div>
</div>
</div>
);
}
interface SiteHeaderProps {
active?: ActiveNav;
subtitle?: ReactNode;
rightSlot?: ReactNode;
}
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"
}`;
return (
<header className="sticky top-0 z-30 border-b border-line bg-surface/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} />
<nav aria-label="Primary" className="ml-auto flex items-center gap-1 whitespace-nowrap">
<Link
href="/recipes"
aria-current={active === "recipes" ? "page" : undefined}
className={linkClass(active === "recipes")}
title="Recipe library"
>
<BookOpen className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
<span className="sr-only md:not-sr-only">Recipes</span>
</Link>
<Link
href="/caydens-corner"
aria-current={active === "caydens-corner" ? "page" : undefined}
className={linkClass(active === "caydens-corner")}
title="Cayden's Corner"
>
<BookHeart className="size-4 shrink-0" strokeWidth={1.75} aria-hidden />
<span className="sr-only md:not-sr-only">Cayden&apos;s Corner</span>
</Link>
{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"
>
<CalendarRange className="size-4 shrink-0" strokeWidth={2} aria-hidden />
<span className="sm:hidden">Planner</span>
<span className="hidden sm:inline">Open planner</span>
</Link>
)}
</nav>
{rightSlot}
<ThemeToggle />
</div>
</header>
);
}

View file

@ -0,0 +1,53 @@
"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>
);
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

194
app/globals.css Normal file
View file

@ -0,0 +1,194 @@
@import "tailwindcss";
/* ---------------------------------------------------------------------------
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)
--------------------------------------------------------------------------- */
@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));
/* 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));
/* 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));
/* 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));
/* 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));
--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-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));
/* 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;
/* Animations */
--animate-fade-in: fade-in 0.35s cubic-bezier(0.16, 1, 0.3, 1);
--animate-preview-timer: preview-timer 1s linear forwards;
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes preview-timer {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
}
@view-transition {
navigation: auto;
}
@layer base {
html {
color-scheme: light dark;
}
html[data-theme="light"] {
color-scheme: light;
}
html[data-theme="dark"] {
color-scheme: dark;
}
body {
@apply bg-canvas text-ink antialiased selection:bg-brand-950 selection:text-ink;
}
/* 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;
}
input[type="range"] {
@apply accent-brand-600;
}
input:not([type="range"], [type="checkbox"]) {
@apply bg-surface text-ink placeholder:text-ink-faint;
}
/* Native <dialog> spring entry/exit via 2026 Baseline @starting-style */
dialog {
color: var(--color-ink);
transition:
opacity 0.22s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.26s cubic-bezier(0.16, 1, 0.3, 1),
display 0.26s allow-discrete,
overlay 0.26s allow-discrete;
opacity: 0;
transform: translateY(14px) scale(0.985);
}
dialog[open] {
opacity: 1;
transform: translateY(0) scale(1);
@starting-style {
opacity: 0;
transform: translateY(14px) scale(0.985);
}
}
dialog.sheet-dialog {
transform: translateY(100%);
}
dialog.sheet-dialog[open] {
transform: translateY(0);
@starting-style {
transform: translateY(100%);
}
}
dialog::backdrop {
background-color: rgb(10 14 18 / 0);
backdrop-filter: blur(0px);
transition:
background-color 0.24s ease,
backdrop-filter 0.24s ease,
display 0.24s allow-discrete,
overlay 0.24s allow-discrete;
}
dialog[open]::backdrop {
background-color: rgb(10 14 18 / 0.55);
backdrop-filter: blur(2px);
@starting-style {
background-color: rgb(10 14 18 / 0);
backdrop-filter: blur(0px);
}
}
}
/* 2026 CSS optical cap-height trimming for buttons, badges & chips */
.trim-cap {
text-box-trim: trim-both;
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);
}
/* Respect the OS "reduce motion" setting everywhere. */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

44
app/layout.tsx Normal file
View file

@ -0,0 +1,44 @@
import type { Metadata, Viewport } from "next";
import { Fraunces, JetBrains_Mono, Plus_Jakarta_Sans } 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"],
});
export const metadata: Metadata = {
// Pages set just their own name ("Recipes"); the template adds the app name.
title: { default: "Plan my Meals: Budget Meal Planner", template: "%s · Plan my Meals" },
description: "Plan a week of meals around your grocery budget and the appliances you actually own.",
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#faf6ef" },
{ media: "(prefers-color-scheme: dark)", color: "#0e171f" },
],
};
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){}})();`;
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en" suppressHydrationWarning className={`${jakarta.variable} ${fraunces.variable} ${jetbrainsMono.variable}`}>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body className="min-h-dvh font-sans">{children}</body>
</html>
);
}

318
app/lib/catalog.ts Normal file
View file

@ -0,0 +1,318 @@
import type { ItemId, StoreItem } from "@/app/types";
// ---------------------------------------------------------------------------
// Store catalog — U.S. average grocery prices as of October 2026.
//
// Core staples are calibrated against the U.S. Bureau of Labor Statistics (BLS)
// CPI Average Retail Food Prices (U.S. City Average, released October 14, 2026),
// e.g. Grade A large eggs ($2.27/doz), boneless chicken breast ($4.20/lb),
// 80/20 ground beef ($6.49/lb), boneless pork chops ($4.55/lb), stick butter
// ($3.96/lb), reduced-fat milk ($3.59/gal), natural cheddar ($6.13/lb), white
// bread ($1.83/lb), white rice ($1.12/lb), dry pasta ($1.38/lb), Russet
// potatoes ($0.99/lb), field tomatoes ($1.99/lb), bananas ($0.66/lb), flour
// ($0.55/lb), and sugar ($1.02/lb). Items outside the BLS APU basket reflect
// October 2026 U.S. national supermarket shelf averages.
//
// Swap in live store pricing by implementing PriceProvider (see pricing.ts).
//
// Conventions:
// - packageSize is in baseUnit (g / ml / each)
// - gramsPerMl only on weight items that recipes measure in cups/tbsp
// ---------------------------------------------------------------------------
const LB = 453.6; // grams
const OZ = 28.35; // grams
const FL_OZ = 29.57; // ml
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: "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 },
{ id: "eggs", name: "Large White Eggs", aisle: "Meat & Eggs", packageSize: 12, baseUnit: "each", packageLabel: "12 ct", price: 2.27 },
{ id: "tofu", name: "Extra Firm Tofu", aisle: "Produce", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz block", price: 2.39 },
// --- 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: "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 },
{ id: "sour_cream", name: "Sour Cream", aisle: "Dairy", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz tub", price: 2.49, gramsPerMl: 1.0 },
{ id: "greek_yogurt", name: "Plain Nonfat Greek Yogurt", aisle: "Dairy", packageSize: 32 * OZ, baseUnit: "g", packageLabel: "32 oz tub", price: 5.29, gramsPerMl: 1.03 },
{ id: "heavy_cream", name: "Heavy Whipping Cream", aisle: "Dairy", packageSize: 16 * FL_OZ, baseUnit: "ml", packageLabel: "1 pint", price: 3.89 },
// --- Produce ---------------------------------------------------------------
{ id: "onion", name: "Yellow Onions", aisle: "Produce", packageSize: 8, baseUnit: "each", packageLabel: "3 lb bag (~8)", price: 3.49 },
{ id: "potatoes", name: "Russet Potatoes", aisle: "Produce", packageSize: 5 * LB, baseUnit: "g", packageLabel: "5 lb bag", price: 4.95 },
{ id: "carrots", name: "Whole Carrots", aisle: "Produce", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 1.99 },
{ id: "bell_pepper", name: "Green Bell Pepper", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 1.09 },
{ id: "spinach", name: "Baby Spinach", aisle: "Produce", packageSize: 10 * OZ, baseUnit: "g", packageLabel: "10 oz clamshell", price: 3.29, gramsPerMl: 0.13 },
{ id: "coleslaw_mix", name: "Classic Coleslaw Mix", aisle: "Produce", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz bag", price: 2.29 },
{ id: "celery", name: "Celery Hearts", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "1 bunch", price: 2.19 },
{ id: "lemon", name: "Lemon", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.69 },
{ id: "lime", name: "Lime", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.45 },
{ id: "green_onions", name: "Green Onions", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "1 bunch", price: 0.99 },
{ id: "bananas", name: "Bananas", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.26 },
// --- Frozen ---------------------------------------------------------------
{ id: "frozen_broccoli", name: "Frozen Broccoli Florets", aisle: "Frozen", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 1.69 },
{ id: "frozen_mixed_veg", name: "Frozen Mixed Vegetables", aisle: "Frozen", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 1.69, gramsPerMl: 0.57 },
{ id: "frozen_berries", name: "Frozen Mixed Berries", aisle: "Frozen", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz bag", price: 3.99, gramsPerMl: 0.6 },
// --- 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: "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: "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 },
{ id: "black_beans", name: "Black Beans, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15 oz can", price: 1.15 },
{ id: "kidney_beans", name: "Dark Red Kidney Beans, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15.5 oz can", price: 1.15 },
{ id: "chickpeas", name: "Chickpeas (Garbanzo), canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15.5 oz can", price: 1.19 },
{ id: "diced_tomatoes", name: "Diced Tomatoes, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "14.5 oz can", price: 1.15 },
{ id: "crushed_tomatoes", name: "Crushed Tomatoes, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "28 oz can", price: 2.19 },
{ id: "marinara", name: "Traditional Pasta Sauce", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "24 oz jar", price: 2.49 },
{ id: "coconut_milk", name: "Coconut Milk, canned", aisle: "Pantry", packageSize: 400, baseUnit: "ml", packageLabel: "13.5 oz can", price: 1.99 },
{ id: "chicken_broth", name: "Chicken Broth", aisle: "Pantry", packageSize: 32 * FL_OZ, baseUnit: "ml", packageLabel: "32 oz carton", price: 2.19 },
{ id: "peanut_butter", name: "Creamy Peanut Butter", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz jar", price: 2.69, gramsPerMl: 1.07 },
{ id: "honey", name: "Clover Honey", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bear", price: 4.29, gramsPerMl: 1.42 },
{ id: "brown_sugar", name: "Light Brown Sugar", aisle: "Pantry", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 2.49, gramsPerMl: 0.9 },
{ id: "pancake_syrup", name: "Original Pancake Syrup", aisle: "Pantry", packageSize: 24 * FL_OZ, baseUnit: "ml", packageLabel: "24 oz bottle", price: 2.79 },
{ id: "cornstarch", name: "Corn Starch", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz box", price: 1.69, gramsPerMl: 0.54 },
// Pantry staples: listed, but excluded from the total if the user owns them.
{ id: "flour", name: "All-Purpose Flour", aisle: "Pantry", packageSize: 5 * LB, baseUnit: "g", packageLabel: "5 lb bag", price: 2.73, gramsPerMl: 0.53, pantryStaple: true },
{ id: "baking_powder", name: "Baking Powder", aisle: "Pantry", packageSize: 8.1 * OZ, baseUnit: "g", packageLabel: "8.1 oz can", price: 1.79, gramsPerMl: 0.9, pantryStaple: true },
{ id: "vegetable_oil", name: "Vegetable Oil", aisle: "Pantry", packageSize: 48 * FL_OZ, baseUnit: "ml", packageLabel: "48 oz bottle", price: 4.29, pantryStaple: true },
{ id: "olive_oil", name: "Extra Virgin Olive Oil", aisle: "Pantry", packageSize: 17 * FL_OZ, baseUnit: "ml", packageLabel: "17 oz bottle", price: 7.99, pantryStaple: true },
{ id: "salt", name: "Iodized Salt", aisle: "Spices & Sauces", packageSize: 26 * OZ, baseUnit: "g", packageLabel: "26 oz canister", price: 1.19, gramsPerMl: 1.2, pantryStaple: true },
{ id: "black_pepper", name: "Ground Black Pepper", aisle: "Spices & Sauces", packageSize: 3 * OZ, baseUnit: "g", packageLabel: "3 oz", price: 2.49, gramsPerMl: 0.47, pantryStaple: true },
// --- Spices & Sauces ---------------------------------------------------------
{ id: "minced_garlic", name: "Minced Garlic (jar)", aisle: "Spices & Sauces", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz jar", price: 2.89, gramsPerMl: 1.1 },
{ id: "soy_sauce", name: "Soy Sauce", aisle: "International", packageSize: 15 * FL_OZ, baseUnit: "ml", packageLabel: "15 oz bottle", price: 2.19 },
{ id: "bbq_sauce", name: "Original BBQ Sauce", aisle: "Spices & Sauces", packageSize: 18 * OZ, baseUnit: "g", packageLabel: "18 oz bottle", price: 1.99, gramsPerMl: 1.1 },
{ id: "salsa", name: "Medium Thick & Chunky Salsa", aisle: "Spices & Sauces", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz jar", price: 2.69, gramsPerMl: 1.05 },
{ id: "taco_seasoning", name: "Taco Seasoning Packet", aisle: "Spices & Sauces", packageSize: 1, baseUnit: "each", packageLabel: "1 oz packet", price: 0.89 },
{ id: "chili_powder", name: "Chili Powder", aisle: "Spices & Sauces", packageSize: 2.5 * OZ, baseUnit: "g", packageLabel: "2.5 oz", price: 1.99, gramsPerMl: 0.55 },
{ id: "cumin", name: "Ground Cumin", aisle: "Spices & Sauces", packageSize: 1.5 * OZ, baseUnit: "g", packageLabel: "1.5 oz", price: 1.99, gramsPerMl: 0.5 },
{ id: "paprika", name: "Paprika", aisle: "Spices & Sauces", packageSize: 2.5 * OZ, baseUnit: "g", packageLabel: "2.5 oz", price: 1.99, gramsPerMl: 0.46 },
{ id: "italian_seasoning", name: "Italian Seasoning", aisle: "Spices & Sauces", packageSize: 0.87 * OZ, baseUnit: "g", packageLabel: "0.87 oz", price: 1.89, gramsPerMl: 0.2 },
{ id: "curry_powder", name: "Curry Powder", aisle: "Spices & Sauces", packageSize: 1.75 * OZ, baseUnit: "g", packageLabel: "1.75 oz", price: 2.49, gramsPerMl: 0.42 },
// --- Seafood (one aisle so fish & shellfish group together on lists) --------
{ id: "salmon", name: "Frozen Atlantic Salmon Fillets", aisle: "Seafood", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 15.99 },
{ id: "tilapia", name: "Frozen Tilapia Fillets", aisle: "Seafood", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 8.99 },
{ id: "pollock", name: "Frozen Wild Alaska Pollock Fillets", aisle: "Seafood", packageSize: 2 * LB, baseUnit: "g", packageLabel: "2 lb bag", price: 7.99 },
{ id: "canned_tuna", name: "Chunk Light Tuna in Water", aisle: "Seafood", packageSize: 1, baseUnit: "each", packageLabel: "5 oz can", price: 1.29 },
{ id: "canned_clams", name: "Chopped Clams, canned", aisle: "Seafood", packageSize: 1, baseUnit: "each", packageLabel: "6.5 oz can", price: 2.19 },
{ id: "imitation_crab", name: "Imitation Crab (Flake Style)", aisle: "Seafood", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz pack", price: 3.99 },
{ id: "old_bay", name: "Old Bay Seafood Seasoning", aisle: "Spices & Sauces", packageSize: 6 * OZ, baseUnit: "g", packageLabel: "6 oz can", price: 4.49, gramsPerMl: 0.6 },
{ id: "mayo", name: "Mayonnaise", aisle: "Spices & Sauces", packageSize: 30 * FL_OZ, baseUnit: "ml", packageLabel: "30 oz jar", price: 4.29 },
{ id: "seaweed_snacks", name: "Roasted Seaweed Snack Sheets", aisle: "International", packageSize: 1, baseUnit: "each", packageLabel: "0.35 oz pack", price: 1.39 },
{ id: "avocado", name: "Hass Avocado", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 1.25 },
// --- 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: "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: "spiral_ham", name: "Spiral-Sliced Bone-In Half Ham", aisle: "Meat & Eggs", packageSize: 8 * LB, baseUnit: "g", packageLabel: "~8 lb", price: 21.99 },
{ 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 },
// Dairy
{ id: "swiss_cheese", name: "Sliced Swiss Cheese", aisle: "Dairy", packageSize: 10, baseUnit: "each", packageLabel: "10 slices", price: 3.49 },
{ id: "romano", name: "Grated Romano Cheese", aisle: "Dairy", packageSize: 5 * OZ, baseUnit: "g", packageLabel: "5 oz", price: 3.99, gramsPerMl: 0.42 },
{ id: "buttermilk", name: "Buttermilk", aisle: "Dairy", packageSize: 32 * FL_OZ, baseUnit: "ml", packageLabel: "1 quart", price: 2.69 },
{ id: "evaporated_milk", name: "Evaporated Milk", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "12 oz can", price: 1.49 },
// Produce & frozen
{ id: "shallots", name: "Shallot", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.89 },
{ id: "fresh_parsley", name: "Fresh Parsley", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "1 bunch", price: 1.19 },
{ id: "fresh_rosemary", name: "Fresh Rosemary", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "0.75 oz pack", price: 2.79 },
{ id: "fresh_thyme", name: "Fresh Thyme", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "0.75 oz pack", price: 2.79 },
{ id: "frozen_spinach", name: "Frozen Chopped Spinach", aisle: "Frozen", packageSize: 10 * OZ, baseUnit: "g", packageLabel: "10 oz box", price: 1.59, gramsPerMl: 0.6 },
// Pantry: cans, jars, condiments
{ id: "capers", name: "Capers", aisle: "Pantry", packageSize: 3.5 * OZ, baseUnit: "g", packageLabel: "3.5 oz jar", price: 2.99, gramsPerMl: 0.6 },
{ id: "white_wine", name: "Dry White Wine (or cooking wine)", aisle: "Pantry", packageSize: 750, baseUnit: "ml", packageLabel: "750 ml bottle", price: 6.99 },
{ id: "sangria", name: "Sangria Wine", aisle: "Pantry", packageSize: 750, baseUnit: "ml", packageLabel: "750 ml bottle", price: 6.99 },
{ id: "chicken_bouillon", name: "Chicken Bouillon Granules", aisle: "Pantry", packageSize: 3.75 * OZ, baseUnit: "g", packageLabel: "3.75 oz jar", price: 1.99, gramsPerMl: 0.8 },
{ id: "sun_dried_tomatoes", name: "Sun-Dried Tomatoes in Oil", aisle: "Pantry", packageSize: 8.5 * OZ, baseUnit: "g", packageLabel: "8.5 oz jar", price: 4.49, gramsPerMl: 0.5 },
{ id: "spanish_olives", name: "Spanish Olives (pitted)", aisle: "Pantry", packageSize: 5.75 * OZ, baseUnit: "g", packageLabel: "5.75 oz jar", price: 2.69, gramsPerMl: 0.6 },
{ id: "black_olives", name: "Sliced Black Olives", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "6 oz can", price: 1.79 },
{ id: "artichoke_hearts", name: "Artichoke Hearts, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "14 oz can", price: 2.99 },
{ id: "french_fried_onions", name: "French Fried Onions", aisle: "Pantry", packageSize: 6 * OZ, baseUnit: "g", packageLabel: "6 oz canister", price: 3.99 },
{ id: "rotel", name: "Diced Tomatoes & Green Chiles", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "10 oz can", price: 1.35 },
{ id: "stewed_tomatoes", name: "Italian-Style Stewed Tomatoes", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "14.5 oz can", price: 1.39 },
{ id: "mango_peach_salsa", name: "Mango Peach Salsa", aisle: "International", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz jar", price: 3.49, gramsPerMl: 1.05 },
{ id: "balsamic_vinegar", name: "Balsamic Vinegar", aisle: "Spices & Sauces", packageSize: 500, baseUnit: "ml", packageLabel: "16.9 oz bottle", price: 3.69 },
{ id: "dijon_mustard", name: "Dijon Mustard", aisle: "Spices & Sauces", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz jar", price: 2.49, gramsPerMl: 1.05 },
{ id: "horseradish", name: "Prepared Horseradish", aisle: "Spices & Sauces", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz jar", price: 2.69, gramsPerMl: 1.0 },
{ id: "chili_oil", name: "Chili Oil", aisle: "International", packageSize: 207, baseUnit: "ml", packageLabel: "7 oz bottle", price: 3.99 },
{ 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: "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: "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 },
{ id: "pecans", name: "Chopped Pecans", aisle: "Pantry", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz bag", price: 5.99, gramsPerMl: 0.45 },
{ id: "marshmallow_creme", name: "Marshmallow Creme", aisle: "Pantry", packageSize: 7 * OZ, baseUnit: "g", packageLabel: "7 oz jar", price: 2.29, gramsPerMl: 0.37 },
{ id: "german_chocolate", name: "German Sweet Chocolate Bar", aisle: "Pantry", packageSize: 4 * OZ, baseUnit: "g", packageLabel: "4 oz bar", price: 3.49 },
{ id: "dark_chocolate_chips", name: "Dark Chocolate Chips", aisle: "Pantry", packageSize: 10 * OZ, baseUnit: "g", packageLabel: "10 oz bag", price: 3.99, gramsPerMl: 0.7 },
{ id: "caramels", name: "Milk Caramels", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "11 oz bag", price: 3.49 },
{ id: "peppermint_kisses", name: "Candy Cane Kisses", aisle: "Pantry", packageSize: 45, baseUnit: "each", packageLabel: "9 oz bag (~45)", price: 4.49 },
// Spices
{ id: "cayenne", name: "Ground Cayenne Pepper", aisle: "Spices & Sauces", packageSize: 1.5 * OZ, baseUnit: "g", packageLabel: "1.5 oz", price: 1.99, gramsPerMl: 0.45 },
{ id: "coriander", name: "Ground Coriander", aisle: "Spices & Sauces", packageSize: 0.9 * OZ, baseUnit: "g", packageLabel: "0.9 oz", price: 2.29, gramsPerMl: 0.4 },
{ id: "nutmeg", name: "Ground Nutmeg", aisle: "Spices & Sauces", packageSize: 1.1 * OZ, baseUnit: "g", packageLabel: "1.1 oz", price: 3.49, gramsPerMl: 0.45 },
{ id: "ground_ginger", name: "Ground Ginger", aisle: "Spices & Sauces", packageSize: 0.8 * OZ, baseUnit: "g", packageLabel: "0.8 oz", price: 2.29, gramsPerMl: 0.45 },
{ id: "onion_powder", name: "Onion Powder", aisle: "Spices & Sauces", packageSize: 2.6 * OZ, baseUnit: "g", packageLabel: "2.6 oz", price: 1.99, gramsPerMl: 0.45 },
{ id: "garlic_salt", name: "Garlic Salt", aisle: "Spices & Sauces", packageSize: 3.25 * OZ, baseUnit: "g", packageLabel: "3.25 oz", price: 1.89, gramsPerMl: 1.0 },
{ id: "dried_oregano", name: "Dried Oregano", aisle: "Spices & Sauces", packageSize: 0.87 * OZ, baseUnit: "g", packageLabel: "0.87 oz", price: 1.89, gramsPerMl: 0.25 },
{ id: "dried_basil", name: "Dried Basil", aisle: "Spices & Sauces", packageSize: 0.62 * OZ, baseUnit: "g", packageLabel: "0.62 oz", price: 1.89, gramsPerMl: 0.25 },
{ id: "dried_thyme", name: "Dried Thyme", aisle: "Spices & Sauces", packageSize: 0.75 * OZ, baseUnit: "g", packageLabel: "0.75 oz", price: 1.89, gramsPerMl: 0.3 },
{ id: "dried_parsley", name: "Dried Parsley Flakes", aisle: "Spices & Sauces", packageSize: 0.5 * OZ, baseUnit: "g", packageLabel: "0.5 oz", price: 1.89, gramsPerMl: 0.1 },
{ id: "dried_rosemary", name: "Dried Rosemary", aisle: "Spices & Sauces", packageSize: 0.75 * OZ, baseUnit: "g", packageLabel: "0.75 oz", price: 1.89, gramsPerMl: 0.25 },
// --- Big recipe expansion ------------------------------------------------------
// 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: "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: "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 },
{ id: "chorizo", name: "Pork Chorizo", aisle: "Meat & Eggs", packageSize: 9 * OZ, baseUnit: "g", packageLabel: "9 oz pack", price: 2.79 },
{ id: "pepperoni", name: "Sliced Pepperoni", aisle: "Meat & Eggs", packageSize: 6 * OZ, baseUnit: "g", packageLabel: "6 oz pack", price: 3.29 },
{ id: "deli_turkey", name: "Sliced Deli Turkey", aisle: "Meat & Eggs", packageSize: 9 * OZ, baseUnit: "g", packageLabel: "9 oz tub", price: 4.29 },
{ id: "canned_chicken", name: "Chunk Chicken Breast, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "12.5 oz can", price: 2.99 },
{ id: "spam", name: "Spam Classic", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "12 oz can", price: 3.79 },
// Seafood
{ id: "fish_sticks", name: "Frozen Breaded Fish Sticks", aisle: "Seafood", packageSize: 24.6 * OZ, baseUnit: "g", packageLabel: "24.6 oz box", price: 5.99 },
{ id: "canned_salmon", name: "Pink Salmon, canned", aisle: "Seafood", packageSize: 1, baseUnit: "each", packageLabel: "14.75 oz can", price: 4.29 },
// Dairy & refrigerated
{ id: "cream_cheese", name: "Cream Cheese", aisle: "Dairy", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz brick", price: 2.69, gramsPerMl: 0.95 },
{ id: "ricotta", name: "Whole Milk Ricotta", aisle: "Dairy", packageSize: 15 * OZ, baseUnit: "g", packageLabel: "15 oz tub", price: 3.49, gramsPerMl: 1.0 },
{ id: "biscuit_dough", name: "Refrigerated Biscuits", aisle: "Dairy", packageSize: 8, baseUnit: "each", packageLabel: "8 ct can", price: 1.99 },
{ id: "crescent_rolls", name: "Refrigerated Crescent Rolls", aisle: "Dairy", packageSize: 8, baseUnit: "each", packageLabel: "8 ct can", price: 2.79 },
{ id: "pizza_dough", name: "Refrigerated Pizza Crust", aisle: "Dairy", packageSize: 1, baseUnit: "each", packageLabel: "13.8 oz can", price: 2.99 },
{ id: "tortellini", name: "Refrigerated Cheese Tortellini", aisle: "Dairy", packageSize: 20 * OZ, baseUnit: "g", packageLabel: "20 oz pack", price: 4.79 },
{ id: "gnocchi", name: "Potato Gnocchi", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz pack", price: 2.79 },
// Produce
{ id: "mushrooms", name: "White Mushrooms", aisle: "Produce", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz pack", price: 2.39, gramsPerMl: 0.3 },
{ id: "zucchini", name: "Zucchini", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 1.09 },
{ id: "cabbage", name: "Green Cabbage", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "1 head", price: 2.29 },
{ id: "romaine", name: "Romaine Hearts", aisle: "Produce", packageSize: 3, baseUnit: "each", packageLabel: "3 ct bag", price: 3.49 },
{ id: "apples", name: "Gala Apple", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.79 },
// Frozen
{ id: "frozen_peas", name: "Frozen Sweet Peas", aisle: "Frozen", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 1.69, gramsPerMl: 0.6 },
{ id: "frozen_green_beans", name: "Frozen Cut Green Beans", aisle: "Frozen", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 1.69 },
{ id: "frozen_hash_browns", name: "Frozen Shredded Hash Browns", aisle: "Frozen", packageSize: 30 * OZ, baseUnit: "g", packageLabel: "30 oz bag", price: 3.49, gramsPerMl: 0.4 },
{ id: "frozen_fries", name: "Frozen Crinkle Fries", aisle: "Frozen", packageSize: 32 * OZ, baseUnit: "g", packageLabel: "32 oz bag", price: 3.69 },
{ id: "potstickers", name: "Frozen Pork Potstickers", aisle: "Frozen", packageSize: 20 * OZ, baseUnit: "g", packageLabel: "20 oz bag", price: 6.29 },
{ id: "cauliflower_rice", name: "Frozen Riced Cauliflower", aisle: "Frozen", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 2.39 },
// Bakery & bread
{ id: "corn_tortillas", name: "White Corn Tortillas", aisle: "Bakery", packageSize: 30, baseUnit: "each", packageLabel: "30 ct", price: 2.49 },
{ id: "hamburger_buns", name: "Hamburger Buns", aisle: "Bakery", packageSize: 8, baseUnit: "each", packageLabel: "8 ct", price: 2.19 },
{ id: "hot_dog_buns", name: "Hot Dog Buns", aisle: "Bakery", packageSize: 8, baseUnit: "each", packageLabel: "8 ct", price: 2.19 },
{ id: "hoagie_rolls", name: "Hoagie Rolls", aisle: "Bakery", packageSize: 6, baseUnit: "each", packageLabel: "6 ct", price: 2.99 },
{ id: "english_muffins", name: "English Muffins", aisle: "Bakery", packageSize: 6, baseUnit: "each", packageLabel: "6 ct", price: 2.49 },
{ id: "bagels", name: "Plain Bagels", aisle: "Bakery", packageSize: 6, baseUnit: "each", packageLabel: "6 ct", price: 3.29 },
{ 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: "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 },
{ id: "panko", name: "Panko Breadcrumbs", aisle: "Pantry", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz canister", price: 2.49, gramsPerMl: 0.25 },
{ id: "tortilla_chips", name: "Tortilla Chips", aisle: "Pantry", packageSize: 13 * OZ, baseUnit: "g", packageLabel: "13 oz bag", price: 3.29 },
{ id: "granola", name: "Oats & Honey Granola", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 3.99, gramsPerMl: 0.4 },
{ id: "chocolate_chips", name: "Semi-Sweet Chocolate Chips", aisle: "Pantry", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 3.69, gramsPerMl: 0.7 },
{ id: "cornbread_mix", name: "Cornbread Muffin Mix", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "8.5 oz box", price: 0.89 },
{ id: "sugar", name: "Granulated Sugar", aisle: "Pantry", packageSize: 4 * LB, baseUnit: "g", packageLabel: "4 lb bag", price: 4.08, gramsPerMl: 0.85, pantryStaple: true },
// Pantry: cans & jars
{ id: "cream_of_mushroom", name: "Cream of Mushroom Soup", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "10.5 oz can", price: 1.39 },
{ id: "cream_of_chicken", name: "Cream of Chicken Soup", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "10.5 oz can", price: 1.39 },
{ id: "refried_beans", name: "Refried Beans", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "16 oz can", price: 1.39 },
{ id: "pinto_beans", name: "Pinto Beans, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15.5 oz can", price: 1.15 },
{ id: "white_beans", name: "Cannellini Beans, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15.5 oz can", price: 1.19 },
{ id: "green_chiles", name: "Diced Green Chiles", aisle: "International", packageSize: 1, baseUnit: "each", packageLabel: "4 oz can", price: 1.25 },
{ id: "chipotle_adobo", name: "Chipotle Peppers in Adobo", aisle: "International", packageSize: 1, baseUnit: "each", packageLabel: "7 oz can", price: 1.99 },
{ id: "salsa_verde", name: "Salsa Verde", aisle: "International", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz jar", price: 2.89, gramsPerMl: 1.05 },
{ id: "alfredo_sauce", name: "Alfredo Pasta Sauce", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15 oz jar", price: 2.69 },
{ id: "tomato_sauce", name: "Tomato Sauce, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15 oz can", price: 1.05 },
{ id: "canned_pumpkin", name: "Pure Pumpkin, canned", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "15 oz can", price: 2.29 },
// Sauces & condiments
{ id: "ketchup", name: "Ketchup", aisle: "Spices & Sauces", packageSize: 38 * OZ, baseUnit: "g", packageLabel: "38 oz bottle", price: 3.19, gramsPerMl: 1.15 },
{ id: "mustard", name: "Yellow Mustard", aisle: "Spices & Sauces", packageSize: 20 * OZ, baseUnit: "g", packageLabel: "20 oz bottle", price: 1.49, gramsPerMl: 1.05 },
{ id: "worcestershire", name: "Worcestershire Sauce", aisle: "Spices & Sauces", packageSize: 10 * FL_OZ, baseUnit: "ml", packageLabel: "10 oz bottle", price: 2.39 },
{ id: "hot_sauce", name: "Louisiana Hot Sauce", aisle: "Spices & Sauces", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 1.79 },
{ id: "buffalo_sauce", name: "Buffalo Wing Sauce", aisle: "Spices & Sauces", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 2.89 },
{ id: "ranch", name: "Ranch Dressing", aisle: "Spices & Sauces", packageSize: 16 * FL_OZ, baseUnit: "ml", packageLabel: "16 oz bottle", price: 2.79 },
{ id: "italian_dressing", name: "Italian Dressing", aisle: "Spices & Sauces", packageSize: 16 * FL_OZ, baseUnit: "ml", packageLabel: "16 oz bottle", price: 1.99 },
{ id: "apple_cider_vinegar", name: "Apple Cider Vinegar", aisle: "Spices & Sauces", packageSize: 16 * FL_OZ, baseUnit: "ml", packageLabel: "16 oz bottle", price: 1.99 },
{ id: "hoisin", name: "Hoisin Sauce", aisle: "International", packageSize: 8.5 * OZ, baseUnit: "g", packageLabel: "8.5 oz jar", price: 3.49, gramsPerMl: 1.2 },
{ id: "oyster_sauce", name: "Oyster Sauce", aisle: "International", packageSize: 9 * OZ, baseUnit: "g", packageLabel: "9 oz bottle", price: 2.99, gramsPerMl: 1.2 },
{ id: "sweet_chili_sauce", name: "Sweet Chili Sauce", aisle: "International", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 2.99 },
{ id: "miso", name: "White Miso Paste", aisle: "International", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz tub", price: 5.49, gramsPerMl: 1.15 },
{ id: "garlic_powder", name: "Garlic Powder", aisle: "Spices & Sauces", packageSize: 3.4 * OZ, baseUnit: "g", packageLabel: "3.4 oz", price: 1.99, gramsPerMl: 0.5 },
{ 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: "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 },
{ id: "fresh_basil", name: "Fresh Basil", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "0.75 oz clamshell", price: 2.79 },
{ id: "jalapeno", name: "Jalapeño Pepper", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.25 },
{ id: "cilantro", name: "Cilantro", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "1 bunch", price: 0.89 },
{ id: "sweet_potatoes", name: "Sweet Potato", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each (~0.7 lb)", price: 0.99 },
{ id: "plantains", name: "Plantain", aisle: "Produce", packageSize: 1, baseUnit: "each", packageLabel: "each", price: 0.69 },
{ id: "frozen_corn", name: "Frozen Sweet Corn", aisle: "Frozen", packageSize: 12 * OZ, baseUnit: "g", packageLabel: "12 oz bag", price: 1.69, gramsPerMl: 0.6 },
{ id: "grits", name: "Quick Grits", aisle: "Pantry", packageSize: 24 * OZ, baseUnit: "g", packageLabel: "24 oz canister", price: 2.69, gramsPerMl: 0.66 },
{ id: "pita_bread", name: "Pita Bread", aisle: "Bakery", packageSize: 6, baseUnit: "each", packageLabel: "6 ct", price: 2.99 },
{ id: "instant_ramen", name: "Instant Ramen Noodles", aisle: "International", packageSize: 1, baseUnit: "each", packageLabel: "3 oz pack", price: 0.45 },
{ id: "peanuts", name: "Dry Roasted Peanuts", aisle: "Pantry", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz jar", price: 3.29, gramsPerMl: 0.6 },
{ id: "pineapple", name: "Pineapple, canned (chunks or slices)", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "20 oz can", price: 1.89 },
{ id: "tomato_paste", name: "Tomato Paste", aisle: "Pantry", packageSize: 1, baseUnit: "each", packageLabel: "6 oz can", price: 0.99 },
{ id: "enchilada_sauce", name: "Red Enchilada Sauce", aisle: "International", packageSize: 1, baseUnit: "each", packageLabel: "10 oz can", price: 1.59 },
{ id: "sesame_oil", name: "Toasted Sesame Oil", aisle: "International", packageSize: 5 * FL_OZ, baseUnit: "ml", packageLabel: "5 oz bottle", price: 3.49 },
{ id: "rice_vinegar", name: "Rice Vinegar", aisle: "International", packageSize: 12 * FL_OZ, baseUnit: "ml", packageLabel: "12 oz bottle", price: 2.79 },
{ id: "teriyaki_sauce", name: "Teriyaki Sauce", aisle: "International", packageSize: 10 * FL_OZ, baseUnit: "ml", packageLabel: "10 oz bottle", price: 2.49 },
{ id: "fish_sauce", name: "Fish Sauce", aisle: "International", packageSize: 200, baseUnit: "ml", packageLabel: "6.76 oz bottle", price: 3.29 },
{ id: "sriracha", name: "Sriracha Hot Chili Sauce", aisle: "International", packageSize: 17 * OZ, baseUnit: "g", packageLabel: "17 oz bottle", price: 3.79, gramsPerMl: 1.1 },
{ id: "gochujang", name: "Gochujang (Korean Chili Paste)", aisle: "International", packageSize: 500, baseUnit: "g", packageLabel: "17.6 oz tub", price: 6.49, gramsPerMl: 1.2 },
{ id: "kimchi", name: "Napa Cabbage Kimchi", aisle: "International", packageSize: 14 * OZ, baseUnit: "g", packageLabel: "14 oz jar", price: 5.49, gramsPerMl: 0.75 },
{ id: "red_curry_paste", name: "Thai Red Curry Paste", aisle: "International", packageSize: 4 * OZ, baseUnit: "g", packageLabel: "4 oz jar", price: 3.69, gramsPerMl: 1.1 },
{ id: "ginger_paste", name: "Ginger Paste (tube)", aisle: "Produce", packageSize: 4 * OZ, baseUnit: "g", packageLabel: "4 oz tube", price: 3.79, gramsPerMl: 1.1 },
{ id: "cajun_seasoning", name: "Creole / Cajun Seasoning", aisle: "Spices & Sauces", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz canister", price: 3.29, gramsPerMl: 0.6 },
{ id: "jerk_seasoning", name: "Jamaican Jerk Seasoning", aisle: "Spices & Sauces", packageSize: 3 * OZ, baseUnit: "g", packageLabel: "3 oz", price: 2.79, gramsPerMl: 0.5 },
{ id: "frozen_mango", name: "Frozen Mango Chunks", aisle: "Frozen", packageSize: 16 * OZ, baseUnit: "g", packageLabel: "16 oz bag", price: 3.99, gramsPerMl: 0.6 },
{ id: "orange_juice", name: "100% Orange Juice", aisle: "Dairy", packageSize: 52 * FL_OZ, baseUnit: "ml", packageLabel: "52 oz bottle", price: 4.49 },
{ id: "almond_milk", name: "Unsweetened Almond Milk", aisle: "Dairy", packageSize: 64 * FL_OZ, baseUnit: "ml", packageLabel: "half gallon", price: 2.99 },
{ id: "cocoa_powder", name: "Unsweetened Cocoa Powder", aisle: "Pantry", packageSize: 8 * OZ, baseUnit: "g", packageLabel: "8 oz canister", price: 4.49, gramsPerMl: 0.36 },
{ id: "cinnamon", name: "Ground Cinnamon", aisle: "Spices & Sauces", packageSize: 2.37 * OZ, baseUnit: "g", packageLabel: "2.37 oz", price: 1.99, gramsPerMl: 0.53 },
];
export const CATALOG_BY_ID: ReadonlyMap<ItemId, StoreItem> = new Map(CATALOG.map((item) => [item.id, item]));
/** Default "have it" list for new users: the basic pantry staples. */
export const PANTRY_STAPLE_IDS: ItemId[] = CATALOG.filter((item) => item.pantryStaple).map((item) => item.id);

58
app/lib/display.ts Normal file
View file

@ -0,0 +1,58 @@
import type { Recipe, RecipeIngredient } from "@/app/types";
import { CATALOG_BY_ID } from "./catalog";
// ---------------------------------------------------------------------------
// Human-friendly ingredient lines, scaled to a serving count.
//
// ing("white_rice", 1.5, "cup") for 4 → shown for 2 as "¾ cup Long Grain White Rice"
// ing("black_beans", 1, "each") → "1 × Black Beans, canned (15 oz can)"
// ---------------------------------------------------------------------------
const FRACTIONS: [number, string][] = [
[0, ""],
[1 / 8, "⅛"],
[1 / 4, "¼"],
[1 / 3, "⅓"],
[1 / 2, "½"],
[2 / 3, "⅔"],
[3 / 4, "¾"],
[1, ""],
];
/** 0.75 → "¾", 1.5 → "1½", 2.33 → "2⅓", 13.2 → "13". */
export function formatQuantity(value: number): string {
if (value >= 10) return String(Math.round(value));
const whole = Math.floor(value);
const fraction = value - whole;
const [nearest, glyph] = FRACTIONS.reduce((best, f) => (Math.abs(f[0] - fraction) < Math.abs(best[0] - fraction) ? f : best));
const total = whole + (nearest === 1 ? 1 : 0);
if (total === 0 && glyph === "") return "⅛"; // never show "0" for a real ingredient
return `${total > 0 ? total : ""}${glyph}` || "0";
}
export interface IngredientLine {
quantity: string;
/** e.g. "cup", "lb", or "×" for counted items. */
unit: string;
name: string;
/** Package hint for counted items, e.g. "15 oz can". */
detail?: string;
}
export 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);
if (ingredient.unit === "each") {
// Single-unit packages (a can, a jar) read better with the package size.
const detail = item && item.packageSize === 1 && item.packageLabel !== "each" ? item.packageLabel : undefined;
return { quantity, unit: "×", name, detail };
}
return { quantity, unit: ingredient.unit, name };
}
export function ingredientLines(recipe: Recipe, servings: number): IngredientLine[] {
const scale = servings / recipe.servings;
return recipe.ingredients.map((i) => ingredientLine(i, scale));
}

112
app/lib/grocery.test.ts Normal file
View file

@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test";
import type { ItemId, StoreItem } from "@/app/types";
import { CATALOG_BY_ID } from "./catalog";
import { Cart, buildGroceryList } from "./grocery";
import type { PriceProvider } from "./pricing";
import { RECIPES, RECIPES_BY_ID } from "./recipes";
import { sanitizeState } from "./sanitize";
import { toBaseUnit } from "./units";
// ---------------------------------------------------------------------------
// The money math. These are the numbers people budget against, so they get
// pinned down here rather than eyeballed in the UI.
//
// Trick: buildGroceryList accepts a price provider. We hand it a fake store
// that stocks ONE item with a package size we choose, so we control exactly
// how many packages a real recipe needs. Every other ingredient is "not
// stocked" and skipped, which keeps the arithmetic to a single line.
// ---------------------------------------------------------------------------
const RECIPE = RECIPES_BY_ID.get("chicken-tikka-masala")!;
const INGREDIENT = RECIPE.ingredients[0];
const REAL_ITEM = CATALOG_BY_ID.get(INGREDIENT.itemId)!;
/** How much of the item one batch of the recipe needs, in the item's base unit. */
const NEEDED = toBaseUnit(INGREDIENT.amount, INGREDIENT.unit, REAL_ITEM);
/** A store that only sells our one item, sized so the recipe needs `packagesNeeded` packages. */
function storeWhereRecipeNeeds(packagesNeeded: number, price = 10): PriceProvider {
const item: StoreItem = { ...REAL_ITEM, packageSize: NEEDED / packagesNeeded, price };
return { label: "test", getItem: (id: ItemId) => (id === item.id ? item : undefined) };
}
/** Cook the recipe once, for exactly as many people as it serves (scale = 1). */
function groceryList(provider: PriceProvider, onHandItems: ItemId[] = []) {
return buildGroceryList([RECIPE.id], { householdSize: RECIPE.servings, onHandItems, provider });
}
describe("whole-package pricing", () => {
test("1.2 packages' worth bills 2 packages", () => {
const { lines, checkoutTotal, usedTotal, carryoverValue } = groceryList(storeWhereRecipeNeeds(1.2));
expect(lines).toHaveLength(1);
expect(lines[0].packages).toBe(2);
expect(checkoutTotal).toBe(20); // 2 × $10 at the register
expect(usedTotal).toBe(12); // 1.2 × $10 actually eaten
expect(carryoverValue).toBe(8); // the rest goes to the pantry
});
test("float noise doesn't buy an extra package (2.000000001 → 2)", () => {
const { lines } = groceryList(storeWhereRecipeNeeds(2 + 1e-9));
expect(lines[0].packages).toBe(2);
});
test("a tiny amount still buys one whole package", () => {
const { lines } = groceryList(storeWhereRecipeNeeds(0.01));
expect(lines[0].packages).toBe(1);
});
test("an on-hand item is listed but costs $0", () => {
const { lines, checkoutTotal, usedTotal } = groceryList(storeWhereRecipeNeeds(1.2), [REAL_ITEM.id]);
expect(lines[0].onHand).toBe(true);
expect(lines[0].packages).toBe(2); // still shown, so you can check your shelf
expect(lines[0].checkoutCost).toBe(0);
expect(checkoutTotal).toBe(0);
expect(usedTotal).toBe(0);
});
});
describe("Cart (the fast path auto-fill and the meal picker use)", () => {
// Cart only looks at the new recipe's ingredients. It must still agree with
// the slow, obvious way: build the whole list with and without, subtract.
test("marginal cost matches building the list with and without the recipe", () => {
const options = { householdSize: 3, onHandItems: [REAL_ITEM.id] };
const planned = RECIPES.filter((_, i) => i % 37 === 0).map((r) => r.id); // a varied ~12-meal plan
const cart = new Cart(options, planned);
const baseline = buildGroceryList(planned, options);
for (const recipe of RECIPES) {
const withIt = buildGroceryList([...planned, recipe.id], options);
const { checkout, eaten } = cart.marginal(recipe.id);
expect(checkout).toBeCloseTo(withIt.checkoutTotal - baseline.checkoutTotal, 1);
expect(eaten).toBeCloseTo(withIt.usedTotal - baseline.usedTotal, 1);
}
});
test("a recipe that reuses an open package adds less than it costs alone", () => {
const cart = new Cart({ householdSize: 2, onHandItems: [] });
const alone = cart.marginal(RECIPE.id).checkout;
cart.add(RECIPE.id);
expect(cart.marginal(RECIPE.id).checkout).toBeLessThan(alone);
});
});
describe("sanitizeState", () => {
test("clamps days and drops slots outside the grid", () => {
const state = sanitizeState({ daysCount: 99, slots: { "9-9": "x" } });
expect(state.daysCount).toBe(7);
expect(state.slots).toEqual({});
});
test("drops slots that point at recipes that don't exist", () => {
const state = sanitizeState({ slots: { "0-3": "not-a-real-recipe", "1-3": RECIPE.id } });
expect(state.slots).toEqual({ "1-3": RECIPE.id });
});
test("survives garbage input", () => {
expect(() => sanitizeState(null)).not.toThrow();
expect(() => sanitizeState("nope")).not.toThrow();
expect(sanitizeState({ mealsPerDay: Number.NaN }).mealsPerDay).toBeGreaterThanOrEqual(1);
});
});

183
app/lib/grocery.ts Normal file
View file

@ -0,0 +1,183 @@
import type { GroceryLine, GrocerySummary, ItemId, StoreItem } from "@/app/types";
import { averageGroceryProvider, type PriceProvider } from "./pricing";
import { RECIPES_BY_ID } from "./recipes";
import { toBaseUnit } from "./units";
// ---------------------------------------------------------------------------
// Grocery aggregation + whole-package costing.
//
// 1. For every planned meal, scale its ingredients to the household size
// and convert to the store item's base unit.
// 2. Sum per store item across all meals.
// 3. Round UP to whole packages. That's the checkout cost.
// 4. Also compute the value actually eaten, so the gap (carryover) is visible.
//
// Why whole packages? You can't buy 0.3 lb of rice. Rounding up is what makes
// ingredient overlap matter: a second rice dish is nearly free once the bag is
// already in the cart.
//
// Two entry points:
// buildGroceryList the full, sorted list for the drawer and budget meter
// Cart "what would adding this recipe cost?", asked hundreds of
// times by auto-fill and the meal picker. It only looks at
// the recipe's own ingredients instead of rebuilding the list.
// ---------------------------------------------------------------------------
export interface GroceryOptions {
householdSize: number;
/** Items already in the kitchen: listed, but cost $0. */
onHandItems: ItemId[];
provider?: PriceProvider;
}
/** Float-safety: 2.0000000001 packages should still be 2. */
const EPSILON = 1e-6;
const packagesFor = (amount: number, item: StoreItem) => Math.max(1, Math.ceil(amount / item.packageSize - EPSILON));
/** What a line costs at checkout and what's actually eaten. On-hand items cost nothing. */
function lineCost(amount: number, item: StoreItem, onHand: boolean) {
if (onHand || amount <= 0) return { checkout: 0, eaten: 0 };
return {
checkout: packagesFor(amount, item) * item.price,
eaten: (amount / item.packageSize) * item.price,
};
}
/** One recipe's ingredients for this household, in each item's base unit. */
interface RecipeNeeds {
title: string;
amounts: Map<ItemId, number>;
}
function recipeNeeds(recipeId: string, householdSize: number, provider: PriceProvider): RecipeNeeds | null {
const recipe = RECIPES_BY_ID.get(recipeId);
if (!recipe) return null;
const scale = householdSize / recipe.servings;
const amounts = new Map<ItemId, number>();
for (const ingredient of recipe.ingredients) {
const item = provider.getItem(ingredient.itemId);
if (!item) continue; // unknown to this price source; skip rather than crash the UI
const amount = toBaseUnit(ingredient.amount, ingredient.unit, item) * scale;
amounts.set(item.id, (amounts.get(item.id) ?? 0) + amount);
}
return { title: recipe.title, amounts };
}
// --- Full list ----------------------------------------------------------------
export function buildGroceryList(recipeIds: string[], options: GroceryOptions): GrocerySummary {
const provider = options.provider ?? averageGroceryProvider;
const onHandItems = new Set(options.onHandItems);
// itemId → running total + which recipes use it
const needs = new Map<ItemId, { amount: number; usedBy: Set<string> }>();
for (const recipeId of recipeIds) {
const recipe = recipeNeeds(recipeId, options.householdSize, provider);
if (!recipe) continue;
for (const [itemId, amount] of recipe.amounts) {
const entry = needs.get(itemId) ?? { amount: 0, usedBy: new Set<string>() };
entry.amount += amount;
entry.usedBy.add(recipe.title);
needs.set(itemId, entry);
}
}
const lines: GroceryLine[] = [];
for (const [itemId, { amount, usedBy }] of needs) {
const item = provider.getItem(itemId)!;
const onHand = onHandItems.has(itemId);
const packages = packagesFor(amount, item);
// Still compute packages for on-hand items: the list shows them so you can check your shelf.
const cost = lineCost(amount, item, onHand);
lines.push({
item,
neededAmount: amount,
packages,
checkoutCost: cost.checkout,
usedCost: cost.eaten,
leftoverAmount: packages * item.packageSize - amount,
onHand,
usedBy: [...usedBy],
});
}
// Group by aisle, on-hand items last, then most expensive first: easy to scan in-store.
lines.sort(
(a, b) => a.item.aisle.localeCompare(b.item.aisle) || Number(a.onHand) - Number(b.onHand) || b.checkoutCost - a.checkoutCost,
);
const checkoutTotal = sum(lines.map((l) => l.checkoutCost));
const usedTotal = sum(lines.map((l) => l.usedCost));
return {
lines,
checkoutTotal: round2(checkoutTotal),
usedTotal: round2(usedTotal),
carryoverValue: round2(checkoutTotal - usedTotal),
sharedItemCount: lines.filter((l) => l.usedBy.length > 1).length,
};
}
// --- Incremental cart ---------------------------------------------------------
/**
* A running cart for "what does this recipe add?" questions.
*
* const cart = new Cart(options, plannedIds);
* cart.marginal("beef-stroganoff") // { checkout: 2.48, eaten: 3.10 }
* cart.add("beef-stroganoff")
*
* Gives the same answer as building the list with and without the recipe and
* subtracting, but only touches the recipe's own ingredients: every other line
* in the cart cancels out.
*/
export class Cart {
private readonly totals = new Map<ItemId, number>();
private readonly needsCache = new Map<string, RecipeNeeds | null>();
private readonly provider: PriceProvider;
private readonly onHandItems: Set<ItemId>;
constructor(
private readonly options: GroceryOptions,
recipeIds: string[] = [],
) {
this.provider = options.provider ?? averageGroceryProvider;
this.onHandItems = new Set(options.onHandItems);
for (const id of recipeIds) this.add(id);
}
add(recipeId: string): void {
for (const [itemId, amount] of this.needsOf(recipeId)) {
this.totals.set(itemId, (this.totals.get(itemId) ?? 0) + amount);
}
}
/** Extra checkout cost and extra eaten value if this recipe were added. */
marginal(recipeId: string): { checkout: number; eaten: number } {
let checkout = 0;
let eaten = 0;
for (const [itemId, amount] of this.needsOf(recipeId)) {
const item = this.provider.getItem(itemId)!;
const onHand = this.onHandItems.has(itemId);
const before = this.totals.get(itemId) ?? 0;
const withIt = lineCost(before + amount, item, onHand);
const without = lineCost(before, item, onHand);
checkout += withIt.checkout - without.checkout;
eaten += withIt.eaten - without.eaten;
}
return { checkout, eaten };
}
private needsOf(recipeId: string): Map<ItemId, number> {
if (!this.needsCache.has(recipeId)) {
this.needsCache.set(recipeId, recipeNeeds(recipeId, this.options.householdSize, this.provider));
}
return this.needsCache.get(recipeId)?.amounts ?? new Map();
}
}
const sum = (values: number[]) => values.reduce((total, v) => total + v, 0);
const round2 = (value: number) => Math.round(value * 100) / 100;

93
app/lib/plan.test.ts Normal file
View file

@ -0,0 +1,93 @@
import { describe, expect, test } from "bun:test";
import type { MealPlanState } from "@/app/types";
import { activeRecipeIds, mealKindFor, mealLabel, visibleMeals } from "./plan";
import { DEFAULT_STATE, migrateV1Slots, sanitizeState } from "./sanitize";
import { RECIPES } from "./recipes";
import { planReducer } from "./usePlanState";
// ---------------------------------------------------------------------------
// Meal slots. Regression tests for the "change meals per day and your
// breakfast turns into lunch" bug. Meal numbers are fixed:
// 1 = Breakfast, 2 = Lunch, 3 = Dinner
// and "meals per day" only decides which ones you can see.
// ---------------------------------------------------------------------------
// Any real recipes will do; names are just for readability.
const PANCAKES = RECIPES.find((r) => r.kind === "breakfast")!.id;
const [SANDWICH, STEW] = RECIPES.filter((r) => r.kind === "main").map((r) => r.id);
/** Monday with all three meals planned. */
const fullMonday: MealPlanState = {
...DEFAULT_STATE,
daysCount: 1,
mealsPerDay: 3,
slots: { "0-1": PANCAKES, "0-2": SANDWICH, "0-3": STEW },
};
/** What the grid shows, as "Label: recipe". */
function visibleGrid(state: MealPlanState): string[] {
return visibleMeals(state.mealsPerDay).map((meal) => `${mealLabel(meal)}: ${state.slots[`0-${meal}`] ?? "—"}`);
}
describe("visible meals", () => {
test("fewer meals hides from the front of the day", () => {
expect(visibleMeals(3).map(mealLabel)).toEqual(["Breakfast", "Lunch", "Dinner"]);
expect(visibleMeals(2).map(mealLabel)).toEqual(["Lunch", "Dinner"]);
expect(visibleMeals(1).map(mealLabel)).toEqual(["Dinner"]);
});
test("only meal 1 is breakfast", () => {
expect([1, 2, 3].map(mealKindFor)).toEqual(["breakfast", "main", "main"]);
});
});
describe("changing meals per day", () => {
test("3 → 2 keeps lunch as lunch and dinner as dinner", () => {
const state = planReducer(fullMonday, { type: "setMealsPerDay", value: 2 });
expect(visibleGrid(state)).toEqual([`Lunch: ${SANDWICH}`, `Dinner: ${STEW}`]);
});
test("3 → 1 → 3 brings breakfast and lunch back (nothing deleted)", () => {
const down = planReducer(fullMonday, { type: "setMealsPerDay", value: 1 });
expect(visibleGrid(down)).toEqual([`Dinner: ${STEW}`]);
const backUp = planReducer(down, { type: "setMealsPerDay", value: 3 });
expect(visibleGrid(backUp)).toEqual([`Breakfast: ${PANCAKES}`, `Lunch: ${SANDWICH}`, `Dinner: ${STEW}`]);
});
test("hidden meals don't count toward the cart", () => {
expect(activeRecipeIds({ ...fullMonday, mealsPerDay: 1 })).toEqual([STEW]);
expect(activeRecipeIds({ ...fullMonday, mealsPerDay: 2 })).toEqual([SANDWICH, STEW]);
});
});
describe("Clear", () => {
test("empties the visible grid but keeps hidden meals", () => {
const lunchAndDinner = { ...fullMonday, mealsPerDay: 2 };
const cleared = planReducer(lunchAndDinner, { type: "clearSlots" });
expect(visibleGrid(cleared)).toEqual(["Lunch: —", "Dinner: —"]);
expect(cleared.slots).toEqual({ "0-1": PANCAKES }); // breakfast was hidden, so untouched
});
});
describe("migrating plans saved before meal numbers were fixed", () => {
test("2 meals/day: old 1/2 (Lunch/Dinner) become 2/3", () => {
const old = { mealsPerDay: 2, slots: { "0-1": SANDWICH, "0-2": STEW } };
expect((migrateV1Slots(old) as MealPlanState).slots).toEqual({ "0-2": SANDWICH, "0-3": STEW });
});
test("1 meal/day: old 1 (Dinner) becomes 3, stale hidden keys are dropped", () => {
const old = { mealsPerDay: 1, slots: { "0-1": STEW, "0-2": SANDWICH } };
expect((migrateV1Slots(old) as MealPlanState).slots).toEqual({ "0-3": STEW });
});
test("3 meals/day: nothing moves", () => {
const old = { mealsPerDay: 3, slots: { "0-1": PANCAKES, "0-3": STEW } };
expect((migrateV1Slots(old) as MealPlanState).slots).toEqual(old.slots);
});
test("migrated plans still pass sanitizeState", () => {
expect(() => sanitizeState(migrateV1Slots({ mealsPerDay: 2, slots: { "0-1": "x" } }))).not.toThrow();
});
});

150
app/lib/plan.ts Normal file
View file

@ -0,0 +1,150 @@
import type { Appliance, CookingStyle, Cuisine, MealKind, MealPlanState, MealSlot, Recipe } from "@/app/types";
import { CATALOG_BY_ID } from "./catalog";
import { RECIPES_BY_ID } from "./recipes";
// ---------------------------------------------------------------------------
// Small, pure helpers about the plan shape. Used by UI and server alike.
// ---------------------------------------------------------------------------
export const DAY_NAMES = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
export const APPLIANCES: { id: Appliance; label: string; icon: string }[] = [
{ id: "stovetop", label: "Stovetop", icon: "🔥" },
{ id: "oven", label: "Oven", icon: "♨️" },
{ id: "air_fryer", label: "Air Fryer", icon: "🌀" },
{ id: "slow_cooker", label: "Slow Cooker", icon: "🍲" },
{ id: "microwave", label: "Microwave", icon: "⚡" },
{ id: "blender", label: "Blender", icon: "🥤" },
{ id: "rice_cooker", label: "Rice Cooker", icon: "🍚" },
];
export const CUISINES: { id: Cuisine; label: string }[] = [
{ id: "american", label: "American" },
{ id: "southern", label: "Southern & Cajun" },
{ id: "mexican", label: "Mexican" },
{ id: "caribbean", label: "Caribbean" },
{ id: "italian", label: "Italian" },
{ id: "mediterranean", label: "Mediterranean" },
{ id: "middle_eastern", label: "Middle Eastern" },
{ id: "indian", label: "Indian" },
{ id: "chinese", label: "Chinese" },
{ id: "japanese", label: "Japanese" },
{ id: "korean", label: "Korean" },
{ id: "thai", label: "Thai" },
];
export const STYLES: { id: CookingStyle; label: string }[] = [
{ id: "comfort", label: "Comfort classics" },
{ id: "quick", label: "Quick (≤25 min)" },
{ id: "healthy", label: "Lighter" },
{ id: "meal_prep", label: "Meal-prep friendly" },
{ id: "vegetarian", label: "Vegetarian" },
{ id: "seafood", label: "Seafood" },
{ id: "caydens_corner", label: "Cayden's Corner" },
];
/** Add the value if it's missing, remove it if it's there. Returns a new array. */
export const toggle = <T,>(list: readonly T[], value: T): T[] =>
list.includes(value) ? list.filter((v) => v !== value) : [...list, value];
export const slotKey = (dayIndex: number, mealNumber: number) => `${dayIndex}-${mealNumber}`;
/**
* Meal numbers are FIXED: 1 = Breakfast, 2 = Lunch, 3 = Dinner.
* "Meals per day" only decides which of them are visible:
* 1 → Dinner 2 → Lunch, Dinner 3 → Breakfast, Lunch, Dinner
* 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;
/** Visible meal numbers for a count, e.g. 2 → [2, 3]. */
export function visibleMeals(mealsPerDay: number): number[] {
const first = MEAL_NAMES.length - mealsPerDay + 1;
return Array.from({ length: mealsPerDay }, (_, i) => first + i);
}
export function mealLabel(mealNumber: number): string {
return MEAL_NAMES[mealNumber - 1] ?? `Meal ${mealNumber}`;
}
export const MEAL_KIND_LABELS: Record<MealKind, string> = {
breakfast: "Breakfast",
main: "Lunch & dinner",
side: "Appetizer & side",
dessert: "Dessert",
};
/** Planner slots are only ever breakfast or a main; sides and desserts are added by hand. */
export function mealKindFor(mealNumber: number): Extract<MealKind, "breakfast" | "main"> {
return mealNumber === 1 ? "breakfast" : "main";
}
/** Every slot currently visible in the grid, in day-then-meal order. */
export function activeSlots(state: Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots">): MealSlot[] {
const result: MealSlot[] = [];
for (let day = 0; day < state.daysCount; day++) {
for (const meal of visibleMeals(state.mealsPerDay)) {
result.push({ dayIndex: day, mealNumber: meal, recipeId: state.slots[slotKey(day, meal)] ?? null });
}
}
return result;
}
/** Recipe IDs in the visible part of the plan (duplicates kept: each is one cooked meal). */
/** The plan with every visible slot emptied (hidden days/meals are kept). */
export function withoutVisibleSlots(state: Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots">): Record<string, string> {
const slots = { ...state.slots };
for (const slot of activeSlots(state)) delete slots[slotKey(slot.dayIndex, slot.mealNumber)];
return slots;
}
export function activeRecipeIds(state: Pick<MealPlanState, "daysCount" | "mealsPerDay" | "slots">): string[] {
return activeSlots(state)
.map((s) => s.recipeId)
.filter((id): id is string => id !== null && RECIPES_BY_ID.has(id));
}
// --- Can this recipe be planned? ---------------------------------------------
export 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"]. */
avoidedIngredients: string[];
}
type BlockerInputs = Pick<MealPlanState, "selectedAppliances" | "avoidedItems">;
export function recipeBlockers(recipe: Recipe, state: BlockerInputs): RecipeBlockers {
return {
missingAppliances: recipe.requiredAppliances
.filter((a) => !state.selectedAppliances.includes(a))
.map((a) => APPLIANCES.find((x) => x.id === a)?.label ?? a),
avoidedIngredients: recipe.ingredients
.filter((i) => state.avoidedItems.includes(i.itemId))
.map((i) => CATALOG_BY_ID.get(i.itemId)?.name ?? i.itemId),
};
}
export function isCookable(recipe: Recipe, state: BlockerInputs): boolean {
const { missingAppliances, avoidedIngredients } = recipeBlockers(recipe, state);
return missingAppliances.length === 0 && avoidedIngredients.length === 0;
}
/** One short line for the UI, e.g. "Needs Oven · Contains Ground Pork". */
export function describeBlockers({ missingAppliances, avoidedIngredients }: RecipeBlockers): string {
return [
missingAppliances.length > 0 ? `Needs ${missingAppliances.join(" + ")}` : "",
avoidedIngredients.length > 0 ? `Contains ${avoidedIngredients.join(", ")}` : "",
]
.filter(Boolean)
.join(" · ");
}
/** Empty preference lists mean "anything goes". */
export function matchesPreferences(recipe: Recipe, cuisines: Cuisine[], styles: CookingStyle[]): boolean {
const cuisineOk = cuisines.length === 0 || cuisines.includes(recipe.cuisine);
const styleOk = styles.length === 0 || recipe.styles.some((s) => styles.includes(s));
return cuisineOk && styleOk;
}

127
app/lib/planner.ts Normal file
View file

@ -0,0 +1,127 @@
import type { FillMode, GeneratePlanResult, MealPlanState, Recipe } from "@/app/types";
import { Cart, buildGroceryList } from "./grocery";
import { activeRecipeIds, activeSlots, isCookable, matchesPreferences, mealKindFor, slotKey, visibleMeals, withoutVisibleSlots } from "./plan";
import { RECIPES } from "./recipes";
// ---------------------------------------------------------------------------
// Auto-fill planner: greedy, overlap-aware.
//
// For each empty slot (in day order), pick the recipe with the LOWEST score:
//
// score = marginal checkout cost ← what buying this meal adds at the register
// + 0.25 × marginal eaten value ← tie-breaker: prefer genuinely cheap food
// + repeat penalty × times used ← keeps the week from being 14× rice and beans
// + small seeded jitter ← "Shuffle" gives a different but still cheap plan
//
// Marginal cost is measured against the cart so far, with whole packages.
// A dish that reuses an already-bought bag of rice therefore scores as nearly
// free, which is exactly the ingredient-overlap behavior we want.
//
// Greedy isn't globally optimal, but it's predictable, fast (a few ms for a
// full week), and easy to reason about. That's the right trade for v1.
// ---------------------------------------------------------------------------
const REPEAT_PENALTY = 1.5; // dollars of "boredom" per previous use
const MAX_USES_PER_WEEK = 2; // hard cap, relaxed only if nothing else fits
const JITTER = 2.5; // dollars of randomness per pick
export function generatePlan(state: MealPlanState, fillMode: FillMode, seed: number): GeneratePlanResult {
const random = mulberry32(seed);
const notes = new Set<string>();
const groceryOptions = { householdSize: state.householdSize, onHandItems: state.onHandItems };
// "empty": keep every pick and fill the gaps. "all" (Shuffle): re-plan
// the visible grid, but keep meals on hidden days/meals untouched.
const slots: Record<string, string> = fillMode === "empty" ? { ...state.slots } : withoutVisibleSlots(state);
// Only the visible plan is bought and eaten, so only it goes in the cart.
const plannedIds = activeRecipeIds({ ...state, slots });
const cart = new Cart(groceryOptions, plannedIds);
const usage = new Map<string, number>();
for (const id of plannedIds) usage.set(id, (usage.get(id) ?? 0) + 1);
// What this kitchen can cook never changes during a run, so work it out once.
const cookable = {
breakfast: RECIPES.filter((r) => r.kind === "breakfast" && isCookable(r, state)),
main: RECIPES.filter((r) => r.kind === "main" && isCookable(r, state)),
};
for (const slot of activeSlots({ ...state, slots })) {
if (slot.recipeId) continue;
const kind = mealKindFor(slot.mealNumber);
const pool = pickCandidatePool(cookable[kind], state, usage, notes);
// Never the same dish twice in one day (next-day leftovers are fine), unless nothing else fits.
const todaysRecipes = new Set(visibleMeals(state.mealsPerDay).map((meal) => slots[slotKey(slot.dayIndex, meal)]));
const notToday = pool.filter((r) => !todaysRecipes.has(r.id));
const candidates = notToday.length > 0 ? notToday : pool;
if (candidates.length === 0) {
notes.add(`No ${kind} recipes fit your appliances and avoided ingredients. Some slots were left empty.`);
continue;
}
let best: { recipe: Recipe; score: number } | null = null;
for (const recipe of candidates) {
const marginal = cart.marginal(recipe.id);
const score =
marginal.checkout + 0.25 * marginal.eaten + REPEAT_PENALTY * (usage.get(recipe.id) ?? 0) + random() * JITTER;
if (!best || score < best.score) best = { recipe, score };
}
const pick = best!.recipe.id;
slots[slotKey(slot.dayIndex, slot.mealNumber)] = pick;
cart.add(pick);
usage.set(pick, (usage.get(pick) ?? 0) + 1);
}
const summary = buildGroceryList(activeRecipeIds({ ...state, slots }), groceryOptions);
if (summary.checkoutTotal > state.targetBudget) {
notes.add(
`The cheapest plan we found is $${summary.checkoutTotal.toFixed(2)}, over your $${state.targetBudget} budget. ` +
"Try fewer meals, more appliances, or looser filters.",
);
}
return { slots, summary, notes: [...notes] };
}
/**
* Candidate pool, loosening filters step by step until something fits:
* 1. appliances + cuisine + style, under the repeat cap
* 2. appliances + cuisine, under the repeat cap
* 3. appliances only, under the repeat cap
* 4. appliances only, repeat cap ignored
* Appliances and avoided ingredients are NEVER relaxed: suggesting an oven
* dish to someone without an oven (or beef to someone avoiding it) is worse
* than an empty slot.
*/
function pickCandidatePool(cookable: Recipe[], state: MealPlanState, usage: Map<string, number>, notes: Set<string>): Recipe[] {
const underCap = (r: Recipe) => (usage.get(r.id) ?? 0) < MAX_USES_PER_WEEK;
const tiers: { pool: Recipe[]; note?: string }[] = [
{ pool: cookable.filter((r) => matchesPreferences(r, state.preferredCuisines, state.preferredStyles) && underCap(r)) },
{ pool: cookable.filter((r) => matchesPreferences(r, state.preferredCuisines, []) && underCap(r)), note: "Relaxed your cooking-style filter to fill every slot." },
{ pool: cookable.filter(underCap), note: "Relaxed your cuisine filter to fill every slot." },
{ pool: cookable, note: "Repeated some recipes more than twice; there aren't enough matches for this many meals." },
];
for (const tier of tiers) {
if (tier.pool.length > 0) {
if (tier.note) notes.add(tier.note);
return tier.pool;
}
}
return [];
}
/** Tiny deterministic PRNG so the same seed always gives the same plan. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

41
app/lib/pricing.ts Normal file
View file

@ -0,0 +1,41 @@
import type { ItemId, StoreItem } from "@/app/types";
import { CATALOG } from "./catalog";
// ---------------------------------------------------------------------------
// PriceProvider: the single seam between "where prices come from" and the
// rest of the app. The cost math only ever calls `getItem`.
//
// To use live retailer prices later:
// 1. Fetch prices on the SERVER (a Server Action or route handler) so the
// API key never reaches the browser.
// 2. Build a snapshot `StoreItem[]` from the response.
// 3. Pass `new SnapshotPriceProvider("Live grocery prices", snapshot)` into
// the grocery/planner functions instead of `averageGroceryProvider`.
// The math stays synchronous and testable because it works on a snapshot.
// ---------------------------------------------------------------------------
export interface PriceProvider {
/** Shown in the UI so users know where prices came from. */
readonly label: string;
getItem(id: ItemId): StoreItem | undefined;
}
export class SnapshotPriceProvider implements PriceProvider {
private readonly byId: Map<ItemId, StoreItem>;
constructor(
readonly label: string,
items: StoreItem[],
) {
this.byId = new Map(items.map((item) => [item.id, item]));
}
getItem(id: ItemId): StoreItem | undefined {
return this.byId.get(id);
}
}
export const averageGroceryProvider: PriceProvider = new SnapshotPriceProvider(
"Based on average grocery prices as of October 2026",
CATALOG,
);

View file

@ -0,0 +1,311 @@
import type { RecipeData } from "@/app/types";
import { ing } from "./ing";
// Breakfasts (all cuisines)
export const BREAKFASTS: RecipeData[] = [
{
id: "overnight-oats",
title: "Berry Overnight Oats",
description: "Stir together the night before. No cooking at all.",
cuisine: "american",
styles: ["healthy", "quick", "meal_prep", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: [],
servings: 4,
ingredients: [ing("oats", 2, "cup"), ing("milk", 2, "cup"), ing("greek_yogurt", 1, "cup"), ing("frozen_berries", 1.5, "cup"), ing("honey", 2, "tbsp")],
},
{
id: "breakfast-burritos",
title: "Egg & Potato Breakfast Burritos",
description: "Make a batch, wrap in foil, reheat all week.",
cuisine: "mexican",
styles: ["meal_prep", "comfort"],
kind: "breakfast",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("eggs", 8, "each"), ing("flour_tortillas", 4, "each"), ing("potatoes", 1, "lb", "diced"), ing("cheddar", 1, "cup"), ing("salsa", 0.5, "cup"), ing("vegetable_oil", 1, "tbsp")],
},
{
id: "pancakes",
title: "Fluffy Pancakes",
description: "Weekend classic from pantry basics.",
cuisine: "american",
styles: ["comfort", "vegetarian"],
kind: "breakfast",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("flour", 2, "cup"), ing("milk", 1.5, "cup"), ing("eggs", 2, "each"), ing("butter", 3, "tbsp"), ing("baking_powder", 1, "tbsp"), ing("brown_sugar", 2, "tbsp"), ing("pancake_syrup", 0.5, "cup")],
},
{
id: "pb-banana-oatmeal",
title: "PB Banana Oatmeal",
description: "Two minutes in the microwave, keeps you full till lunch.",
cuisine: "american",
styles: ["quick", "healthy", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["microwave"],
servings: 4,
ingredients: [ing("oats", 2, "cup"), ing("milk", 3, "cup"), ing("peanut_butter", 4, "tbsp"), ing("bananas", 2, "each"), ing("honey", 2, "tbsp")],
},
{
id: "air-fryer-potato-hash",
title: "Air Fryer Potato Hash & Eggs",
description: "Crispy potatoes and peppers with fried eggs on top.",
cuisine: "american",
styles: ["comfort"],
kind: "breakfast",
prepTime: 25,
requiredAppliances: ["air_fryer", "stovetop"],
servings: 4,
ingredients: [ing("potatoes", 2, "lb", "cubed"), ing("onion", 1, "each"), ing("bell_pepper", 1, "each"), ing("eggs", 8, "each"), ing("vegetable_oil", 2, "tbsp"), ing("paprika", 1, "tsp"), ing("salt", 1, "tsp")],
},
{
id: "egg-muffin-cups",
title: "Bacon & Spinach Egg Muffins",
description: "Bake a dozen, grab two on the way out.",
cuisine: "american",
styles: ["meal_prep", "healthy"],
kind: "breakfast",
prepTime: 30,
requiredAppliances: ["oven"],
servings: 4,
ingredients: [ing("eggs", 10, "each"), ing("bacon", 6, "oz"), ing("spinach", 2, "oz"), ing("cheddar", 0.75, "cup")],
},
{
id: "microwave-scramble",
title: "Microwave Spinach Scramble",
description: "Eggs, spinach and cheese in a mug.",
cuisine: "american",
styles: ["quick", "vegetarian", "healthy"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["microwave"],
servings: 4,
ingredients: [ing("eggs", 8, "each"), ing("spinach", 2, "oz"), ing("cheddar", 0.5, "cup"), ing("salsa", 0.25, "cup")],
},
// --- Added: more cuisines -------------------------------------------------
{
id: "shakshuka",
title: "Shakshuka with Warm Pita",
description: "Eggs poached in spiced tomato and pepper sauce.",
cuisine: "middle_eastern",
styles: ["comfort", "vegetarian", "healthy"],
kind: "breakfast",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("eggs", 6, "each"), ing("crushed_tomatoes", 1, "each"), ing("onion", 1, "each"), ing("bell_pepper", 1, "each"), ing("cumin", 1, "tsp"), ing("paprika", 1, "tsp"), ing("feta", 0.25, "cup"), ing("pita_bread", 4, "each")],
},
{
id: "biscuits-and-gravy",
title: "Biscuits & Sausage Gravy",
description: "Drop biscuits from scratch under peppery sausage gravy.",
cuisine: "southern",
styles: ["comfort"],
kind: "breakfast",
prepTime: 35,
requiredAppliances: ["stovetop", "oven"],
servings: 4,
ingredients: [ing("flour", 2.5, "cup"), ing("butter", 6, "tbsp"), ing("baking_powder", 1, "tbsp"), ing("milk", 3, "cup"), ing("breakfast_sausage", 1, "lb"), ing("black_pepper", 1, "tsp")],
},
{
id: "sweet-potato-hash",
title: "Air Fryer Sweet Potato & Sausage Hash",
description: "Crispy sweet potato cubes, sausage and a fried egg.",
cuisine: "southern",
styles: ["healthy", "meal_prep"],
kind: "breakfast",
prepTime: 30,
requiredAppliances: ["air_fryer", "stovetop"],
servings: 4,
ingredients: [ing("sweet_potatoes", 3, "each", "cubed"), ing("breakfast_sausage", 0.5, "lb"), ing("onion", 1, "each"), ing("bell_pepper", 1, "each"), ing("eggs", 4, "each"), ing("olive_oil", 2, "tbsp")],
},
{
id: "huevos-rancheros",
title: "Huevos Rancheros",
description: "Fried eggs on warm tortillas with beans and salsa.",
cuisine: "mexican",
styles: ["vegetarian", "quick"],
kind: "breakfast",
prepTime: 15,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("eggs", 8, "each"), ing("flour_tortillas", 4, "each"), ing("black_beans", 1, "each"), ing("salsa", 0.75, "cup"), ing("cheddar", 0.5, "cup"), ing("cilantro", 1, "each")],
},
{
id: "plantains-and-eggs",
title: "Fried Plantains, Eggs & Black Beans",
description: "Sweet caramelized plantains with savory eggs and beans.",
cuisine: "caribbean",
styles: ["comfort", "vegetarian"],
kind: "breakfast",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("plantains", 3, "each"), ing("eggs", 8, "each"), ing("black_beans", 1, "each"), ing("vegetable_oil", 3, "tbsp"), ing("salsa", 0.5, "cup")],
},
{
id: "chicken-congee",
title: "Slow Cooker Chicken & Ginger Congee",
description: "Silky rice porridge that cooks itself overnight.",
cuisine: "chinese",
styles: ["comfort", "healthy", "meal_prep"],
kind: "breakfast",
prepTime: 10,
requiredAppliances: ["slow_cooker"],
servings: 4,
ingredients: [ing("white_rice", 1, "cup"), ing("chicken_broth", 6, "cup"), ing("chicken_thighs", 0.75, "lb"), ing("ginger_paste", 1, "tbsp"), ing("green_onions", 1, "each"), ing("soy_sauce", 2, "tbsp"), ing("sesame_oil", 1, "tsp")],
},
{
id: "tamago-rice-bowl",
title: "Sweet Omelet Rice Bowl",
description: "Japanese-style rolled egg over rice with soy and greens.",
cuisine: "japanese",
styles: ["quick", "vegetarian"],
kind: "breakfast",
prepTime: 15,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("eggs", 8, "each"), ing("white_rice", 1.5, "cup"), ing("soy_sauce", 2, "tbsp"), ing("brown_sugar", 1, "tbsp"), ing("green_onions", 1, "each"), ing("spinach", 2, "oz")],
},
{
id: "gyeran-bap",
title: "Korean Egg Rice with Kimchi",
description: "Gyeran-bap: crispy fried egg, sesame-soy rice, kimchi.",
cuisine: "korean",
styles: ["quick", "comfort", "vegetarian"],
kind: "breakfast",
prepTime: 10,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("white_rice", 1.5, "cup"), ing("eggs", 4, "each"), ing("soy_sauce", 2, "tbsp"), ing("sesame_oil", 1, "tbsp"), ing("green_onions", 1, "each"), ing("kimchi", 1, "cup")],
},
// ===== Smoothies & blender breakfasts =====================================
{
id: "berry-banana-smoothie",
title: "Berry Banana Smoothie",
description: "The classic: frozen berries, banana, yogurt.",
cuisine: "american",
styles: ["quick", "healthy", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("frozen_berries", 2, "cup"), ing("bananas", 2, "each"), ing("greek_yogurt", 1, "cup"), ing("milk", 1.5, "cup"), ing("honey", 1, "tbsp")],
},
{
id: "tropical-smoothie",
title: "Mango Pineapple Smoothie",
description: "Sunny and sweet, no added sugar needed.",
cuisine: "caribbean",
styles: ["quick", "healthy", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("frozen_mango", 2, "cup"), ing("pineapple", 1, "each"), ing("orange_juice", 1, "cup"), ing("greek_yogurt", 1, "cup"), ing("bananas", 1, "each")],
},
{
id: "pb-oat-smoothie",
title: "Peanut Butter Banana Oat Smoothie",
description: "A breakfast-in-a-glass that actually keeps you full.",
cuisine: "american",
styles: ["quick", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("bananas", 3, "each"), ing("peanut_butter", 4, "tbsp"), ing("oats", 0.5, "cup"), ing("milk", 3, "cup"), ing("honey", 1, "tbsp"), ing("cinnamon", 0.5, "tsp")],
},
{
id: "green-smoothie",
title: "Green Mango Smoothie",
description: "Spinach you can't taste, thanks to mango and banana.",
cuisine: "american",
styles: ["quick", "healthy", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("spinach", 3, "oz"), ing("bananas", 2, "each"), ing("frozen_mango", 1.5, "cup"), ing("almond_milk", 2, "cup"), ing("greek_yogurt", 0.5, "cup")],
},
{
id: "chocolate-pb-smoothie",
title: "Chocolate Peanut Butter Smoothie",
description: "Tastes like a milkshake, built like breakfast.",
cuisine: "american",
styles: ["quick", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("cocoa_powder", 2, "tbsp"), ing("bananas", 3, "each"), ing("peanut_butter", 3, "tbsp"), ing("almond_milk", 3, "cup"), ing("honey", 1, "tbsp")],
},
{
id: "mango-lassi",
title: "Mango Lassi",
description: "Creamy Indian yogurt-mango drink.",
cuisine: "indian",
styles: ["quick", "vegetarian"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("frozen_mango", 3, "cup"), ing("greek_yogurt", 1.5, "cup"), ing("milk", 1, "cup"), ing("honey", 2, "tbsp"), ing("cinnamon", 0.25, "tsp")],
},
{
id: "berry-smoothie-bowls",
title: "Berry Smoothie Bowls",
description: "Thick, spoonable smoothie topped with toasted oats and banana.",
cuisine: "american",
styles: ["healthy", "vegetarian"],
kind: "breakfast",
prepTime: 10,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("frozen_berries", 3, "cup"), ing("bananas", 2, "each"), ing("greek_yogurt", 1, "cup"), ing("almond_milk", 0.5, "cup"), ing("oats", 0.5, "cup"), ing("honey", 2, "tbsp")],
},
{
id: "blender-oat-pancakes",
title: "Blender Banana Oat Pancakes",
description: "No flour, no bowl: blend the batter and pour.",
cuisine: "american",
styles: ["healthy", "vegetarian"],
kind: "breakfast",
prepTime: 20,
requiredAppliances: ["blender", "stovetop"],
servings: 4,
ingredients: [ing("oats", 2, "cup"), ing("bananas", 2, "each"), ing("eggs", 2, "each"), ing("milk", 1, "cup"), ing("baking_powder", 2, "tsp"), ing("cinnamon", 1, "tsp"), ing("pancake_syrup", 0.25, "cup")],
},
{
id: "blender-banana-muffins",
title: "Blender Banana Oat Muffins",
description: "Bake a dozen Sunday, grab-and-go all week.",
cuisine: "american",
styles: ["meal_prep", "healthy", "vegetarian"],
kind: "breakfast",
prepTime: 30,
requiredAppliances: ["blender", "oven"],
servings: 6,
ingredients: [ing("oats", 2.5, "cup"), ing("bananas", 3, "each"), ing("eggs", 2, "each"), ing("greek_yogurt", 0.5, "cup"), ing("honey", 0.33, "cup"), ing("baking_powder", 1.5, "tsp"), ing("cinnamon", 1, "tsp")],
},
{
id: "rice-cooker-oatmeal",
title: "Rice Cooker Cinnamon Banana Oatmeal",
description: "Set it on the porridge setting and walk away.",
cuisine: "american",
styles: ["comfort", "vegetarian", "meal_prep"],
kind: "breakfast",
prepTime: 5,
requiredAppliances: ["rice_cooker"],
servings: 4,
ingredients: [ing("oats", 2, "cup"), ing("milk", 3, "cup"), ing("bananas", 2, "each"), ing("cinnamon", 1, "tsp"), ing("brown_sugar", 2, "tbsp")],
},
];

View file

@ -0,0 +1,70 @@
import { dishes } from "./dish";
// ---------------------------------------------------------------------------
// Cayden's Corner: family recipes.
//
// Transcribed from a family recipe collection. Ingredients are mapped to the nearest store
// catalog item (e.g. ziti → penne, linguine → spaghetti, garlic cloves → jar
// garlic at ½ tsp per clove). "To taste" seasonings are left to the steps.
// Directions live in steps/caydens-corner.ts, in their original wording.
//
// These recipes feed a family of four with leftovers, so mains default
// to 6 servings. Every recipe carries the "caydens_corner" style.
// ---------------------------------------------------------------------------
// --- Appetizers & Side Dishes ---------------------------------------------------
export const CAYDENS_CORNER_SIDES = dishes({ cuisine: "american", kind: "side" }, [
{ id: "family-buffalo-chicken-dip", title: "Buffalo Chicken Dip", mins: 50, serves: 8, tools: ["stovetop", "slow_cooker"], styles: ["caydens_corner", "comfort"], about: "Creamy, spicy, and gone in minutes. Frank's Red Hot is really good here.", items: "canned_chicken 2 each; hot_sauce 0.75 cup; cream_cheese 16 oz; ranch 1 cup; cheddar 1.5 cup; celery 1 each; crackers 1 each" },
{ id: "family-crab-stuffed-mushrooms", title: "Crab Stuffed Mushrooms", mins: 35, serves: 6, tools: ["oven"], styles: ["caydens_corner", "seafood"], about: "Mushroom caps filled with herby crab and Parmesan.", items: "canned_crab 7 oz; green_onions 1 each; dried_thyme 0.5 tsp; dried_oregano 0.25 tsp; black_pepper 0.25 tsp; parmesan 7 tbsp; mayo 0.33 cup; mushrooms 16 oz; paprika 0.25 tsp" },
{ id: "family-hot-spinach-artichoke-dip", title: "Hot Spinach Artichoke Dip", mins: 35, serves: 8, tools: ["oven"], styles: ["caydens_corner", "vegetarian", "comfort"], about: "Bubbly, cheesy, three-cheese dip.", items: "cream_cheese 8 oz; mayo 0.25 cup; parmesan 0.25 cup; romano 0.25 cup; minced_garlic 0.5 tsp; dried_basil 0.5 tsp; garlic_salt 0.25 tsp; artichoke_hearts 1 each; frozen_spinach 0.5 cup; mozzarella 0.25 cup" },
{ id: "family-maris-jalapeno-poppers", title: "Mari's Jalapeño Poppers", mins: 25, serves: 6, tools: ["air_fryer"], styles: ["caydens_corner", "vegetarian", "quick"], about: "Cream cheese and cheddar stuffed jalapeños, air-fried golden.", items: "jalapeno 12 each; cream_cheese 8 oz; cheddar 1 cup; garlic_powder 1 tsp; panko 0.25 cup; butter 1 tbsp" },
]);
// --- Main Dishes --------------------------------------------------------------------
export const CAYDENS_CORNER_MAINS = dishes({ cuisine: "american" }, [
{ id: "family-baked-ziti", title: "Baked Ziti", cuisine: "italian", mins: 65, serves: 8, tools: ["stovetop", "oven"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Layers of ziti, Italian sausage, ricotta and a whole pound of mozzarella.", items: "penne 16 oz; italian_sausage 1 lb; marinara 2 each; dried_basil 1.5 tsp; ricotta 12 oz; italian_seasoning 1.5 tsp; mozzarella 16 oz" },
{ id: "family-beef-stroganoff", title: "Beef Stroganoff", mins: 25, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "comfort", "quick"], about: "The weeknight version: ground beef, mushroom soup, sour cream, egg noodles.", items: "egg_noodles 8 oz; ground_beef 1 lb; cream_of_mushroom 1 each; garlic_powder 1 tbsp; sour_cream 0.5 cup" },
{ id: "family-chicken-piccata", title: "Chicken Piccata", cuisine: "italian", mins: 30, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "quick"], about: "Pan-seared chicken in a lemony white wine, butter and caper sauce.", items: "chicken_breast 2 lb; cayenne 0.25 tsp; flour 0.33 cup; olive_oil 2 tbsp; capers 1 tbsp; white_wine 0.5 cup; lemon 2 each; butter 3 tbsp; fresh_parsley 1 each" },
{ id: "family-chili", title: "Chili", mins: 100, serves: 8, tools: ["stovetop"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Two kinds of beans, a little cocoa, and a real cayenne kick.", items: "ground_beef 2 lb; onion 1 each; garlic_powder 2 tbsp; chili_powder 2 tbsp; salt 1 tsp; cumin 2 tsp; dried_oregano 2 tsp; cocoa_powder 2 tsp; hot_sauce 2 tsp; cayenne 1.5 tbsp; diced_tomatoes 2 each; kidney_beans 2 each; black_beans 2 each" },
{ id: "family-chicken-cordon-bleu", title: "Classic Chicken Cordon Bleu", mins: 50, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "comfort"], about: "Ham and Swiss rolled in chicken, simmered in wine, finished with a cream sauce.", items: "flour 3 tbsp; paprika 1 tsp; chicken_breast 2 lb; swiss_cheese 4 each; deli_ham 4 oz; butter 6 tbsp; white_wine 0.5 cup; chicken_bouillon 1 tsp; heavy_cream 1 cup; cornstarch 1 tbsp" },
{ id: "family-coconut-curry-shrimp", title: "Coconut Curry Shrimp", cuisine: "indian", mins: 20, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "seafood", "quick"], about: "Jumbo shrimp in a light coconut curry with cumin and coriander.", items: "vegetable_oil 1 tsp; onion 0.5 each; bell_pepper 0.5 each; minced_garlic 0.5 tsp; cumin 1 tsp; coriander 0.75 tsp; curry_powder 0.5 tsp; coconut_milk 0.5 cup; sugar 1 tsp; red_pepper_flakes 0.25 tsp; shrimp 1 lb; cornstarch 1 tbsp; cilantro 1 each" },
{ id: "family-creamy-southwest-chicken", title: "Creamy Southwest Chicken", cuisine: "mexican", mins: 25, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "quick"], about: "Bite-size chicken in a green chile cream sauce under melted cheese.", items: "chicken_breast 1 lb; onion 0.25 each; minced_garlic 1 tsp; green_chiles 1.5 each; heavy_cream 0.25 cup; cheddar 0.33 cup; olive_oil 1 tbsp" },
{ 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-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" },
{ id: "family-mango-chili-chicken", title: "Mango Chili Chicken", cuisine: "mexican", mins: 75, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "healthy"], about: "Chili-rubbed grilled chicken topped with mango peach salsa.", items: "chicken_breast 1.5 lb; chili_powder 2 tbsp; dried_oregano 1 tsp; cumin 1 tsp; garlic_powder 1 tsp; salt 0.25 tsp; black_pepper 0.25 tsp; olive_oil 2 tbsp; mango_peach_salsa 1 cup" },
{ id: "family-marry-me-chicken", title: "Marry Me Chicken", cuisine: "italian", mins: 25, serves: 4, tools: ["stovetop", "slow_cooker"], styles: ["caydens_corner", "comfort"], about: "Seared chicken slow-cooked in a creamy sun-dried tomato Parmesan sauce.", items: "chicken_breast 2 lb; salt 1 tsp; garlic_powder 0.5 tsp; onion_powder 0.5 tsp; black_pepper 0.75 tsp; olive_oil 2 tbsp; butter 2 tbsp; chicken_broth 1.5 cup; cornstarch 3 tbsp; heavy_cream 1 cup; minced_garlic 2 tsp; italian_seasoning 1.5 tsp; chili_oil 1 tsp; paprika 0.5 tsp; sun_dried_tomatoes 0.33 cup; parmesan 0.5 cup; fresh_basil 1 each" },
{ id: "family-prime-rib", title: "Prime Rib", mins: 240, serves: 10, tools: ["oven"], styles: ["caydens_corner"], about: "Low-and-slow reverse-seared rib roast with horseradish cream sauce. A holiday showstopper.", items: "rib_roast 6 lb; salt 0.25 cup; black_pepper 2 tbsp; fresh_rosemary 1 each; fresh_thyme 1 each; minced_garlic 3 tbsp; dijon_mustard 3 tbsp; horseradish 1 tbsp; sour_cream 2 tbsp; mayo 2 tbsp; worcestershire 1 tsp; lemon 1 each" },
{ id: "family-sangria-ham", title: "Sangria Ham", mins: 150, serves: 12, tools: ["oven"], styles: ["caydens_corner"], about: "Brown-sugar-packed spiral ham with pineapple, basted in sangria.", items: "spiral_ham 8 lb; brown_sugar 1 lb; pineapple 1 each; sangria 2 cup" },
{ id: "family-sausage-peppers-and-onions", title: "Sausage, Peppers, and Onions", cuisine: "italian", mins: 30, serves: 4, tools: ["microwave", "stovetop"], styles: ["caydens_corner", "quick", "healthy"], about: "Turkey sausage with sweet peppers and onions, finished with balsamic.", items: "onion 2 each; bell_pepper 2 each; turkey_sausage 20 oz; olive_oil 1 tbsp; dried_oregano 0.5 tsp; dried_basil 0.5 tsp; minced_garlic 3 tsp; balsamic_vinegar 1.5 tbsp; hoagie_rolls 4 each" },
{ id: "family-shrimp-scampi", title: "Shrimp Scampi", cuisine: "italian", mins: 25, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "seafood", "quick"], about: "Linguine tossed with shrimp, shallots, white wine, lemon and a little chili oil.", items: "spaghetti 16 oz; butter 4 tbsp; olive_oil 4.33 tbsp; shallots 2 each; minced_garlic 1 tsp; shrimp 1 lb; white_wine 0.5 cup; lemon 1 each; fresh_parsley 1 each; chili_oil 1 tsp" },
{ id: "family-slow-cooker-chicken-and-dumplings", title: "Slow Cooker Chicken and Dumplings", mins: 15, serves: 6, tools: ["slow_cooker"], styles: ["caydens_corner", "comfort"], about: "Creamy chicken and veggies with torn biscuit dumplings.", items: "chicken_breast 2 lb; butter 2 tbsp; cream_of_chicken 2 each; frozen_mixed_veg 12 oz; onion 1 each; biscuit_dough 8 each" },
{ id: "family-slow-cooker-creamy-chicken-taco-soup", title: "Slow Cooker Creamy Chicken Taco Soup", cuisine: "mexican", mins: 20, serves: 6, tools: ["slow_cooker"], styles: ["caydens_corner", "comfort"], about: "Taco-spiced shredded chicken in a cream cheese broth with Ro-Tel and chiles.", items: "onion 1 each; bell_pepper 1 each; salt 2 tsp; chicken_broth 2 cup; rotel 1 each; cream_of_mushroom 1 each; green_chiles 1 each; vegetable_oil 2 tbsp; taco_seasoning 0.5 each; cumin 0.5 tsp; black_pepper 0.25 tsp; chicken_breast 1 lb; cream_cheese 8 oz" },
{ id: "family-slow-cooker-honey-garlic-chicken", title: "Slow Cooker Honey Garlic Chicken", cuisine: "chinese", mins: 15, serves: 6, tools: ["slow_cooker", "stovetop"], styles: ["caydens_corner", "meal_prep"], about: "Sticky honey, soy and hoisin chicken with a sriracha kick.", tip: "You can add sesame seeds and the green parts of the scallions for a nice garnish.", items: "chicken_breast 2 lb; soy_sauce 0.5 cup; honey 0.5 cup; hoisin 0.25 cup; rice_vinegar 1 tbsp; sesame_oil 1 tsp; ginger_paste 1 tsp; minced_garlic 2 tsp; sriracha 1.5 tsp; green_onions 1 each; cornstarch 1 tbsp" },
{ id: "family-slow-cooker-pot-roast", title: "Slow Cooker Pot Roast", mins: 20, serves: 8, tools: ["slow_cooker"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Bottom round roast with potatoes, carrots, onion and green beans.", items: "bottom_round_roast 4 lb; potatoes 1.5 lb; carrots 0.75 lb; onion 1 each; frozen_green_beans 8 oz" },
{ id: "family-slow-cooker-tuscan-chicken", title: "Slow Cooker Tuscan Chicken", cuisine: "italian", mins: 20, serves: 6, tools: ["slow_cooker", "stovetop"], styles: ["caydens_corner", "comfort"], about: "Herbed thighs with sun-dried tomatoes, olives and a garlicky spinach cream sauce.", items: "chicken_thighs 2.2 lb; dried_rosemary 1.5 tsp; dried_oregano 1.5 tsp; dried_parsley 1.5 tsp; salt 1 tsp; black_pepper 2 tsp; sun_dried_tomatoes 1 cup; spanish_olives 0.5 cup; olive_oil 0.25 cup; onion 1 each; minced_garlic 4 tsp; heavy_cream 1.5 cup; spinach 2.5 cup; cornstarch 2 tbsp" },
{ id: "family-spicy-thai-basil-chicken", title: "Spicy Thai Basil Chicken", cuisine: "thai", mins: 25, serves: 4, tools: ["stovetop"], styles: ["caydens_corner", "quick"], about: "Chili-oil chicken with mushrooms, peppers, onions and fresh basil.", items: "chili_oil 2 tbsp; minced_garlic 1 tsp; jalapeno 3 each; chicken_breast 1 lb; sugar 1.5 tsp; garlic_salt 1 tsp; black_pepper 1 tsp; oyster_sauce 5 tbsp; mushrooms 1 cup; bell_pepper 2 each; onion 1 each; fresh_basil 1 each" },
{ id: "family-zucchini-and-ground-beef-skillet", title: "Zucchini and Ground Beef Skillet", mins: 45, serves: 6, tools: ["stovetop"], styles: ["caydens_corner", "healthy"], about: "Beef, mushrooms, olives and zucchini in tomato sauce under sharp cheddar.", items: "ground_beef 1 lb; onion 1 each; stewed_tomatoes 1 each; mushrooms 8 oz; tomato_sauce 0.5 each; black_olives 1 each; minced_garlic 1 tsp; zucchini 4 each; cheddar 8 oz" },
// The one breakfast in the collection
{ id: "family-christmas-sausage-egg-casserole", title: "Christmas Sausage Egg Casserole", kind: "breakfast", mins: 55, serves: 6, tools: ["stovetop", "oven"], styles: ["caydens_corner", "comfort", "meal_prep"], about: "Sausage, hash browns, eggs and cheddar. Christmas morning tradition.", tip: "You can prepare this the night before and refrigerate until the next day if you want to save on morning prep time.", items: "breakfast_sausage 1 lb; onion 1 each; eggs 6 each; milk 0.5 cup; frozen_hash_browns 16 oz; cheddar 8 oz" },
]);
// --- Desserts --------------------------------------------------------------------------
export const CAYDENS_CORNER_DESSERTS = dishes({ cuisine: "american", kind: "dessert" }, [
{ id: "family-apple-crisp", title: "Apple Crisp", mins: 60, serves: 12, tools: ["oven"], styles: ["caydens_corner", "comfort"], about: "Cinnamon apples under a buttery oat crumble.", tip: "Serve à la mode with vanilla ice cream for a special treat!", items: "apples 10 each; sugar 1 cup; flour 1 tbsp; cinnamon 1 tsp; oats 1 cup; flour 1 cup; brown_sugar 1 cup; baking_powder 0.25 tsp; baking_soda 0.25 tsp; butter 0.5 cup" },
{ id: "family-apple-strudel-muffins", title: "Apple Strudel Muffins", mins: 35, serves: 12, tools: ["oven"], styles: ["caydens_corner"], about: "Apple-studded muffins with a brown sugar crumb top.", items: "flour 2 cup; baking_powder 1 tsp; baking_soda 0.5 tsp; salt 0.5 tsp; sugar 1 cup; butter 0.5 cup; eggs 2 each; vanilla_extract 1.25 tsp; apples 2 each; brown_sugar 0.33 cup; flour 1 tbsp; cinnamon 0.125 tsp; butter 1 tbsp" },
{ id: "family-chocolate-chip-pumpkin-bread", title: "Chocolate Chip Pumpkin Bread", mins: 75, serves: 16, tools: ["oven"], styles: ["caydens_corner"], about: "Two spiced pumpkin loaves loaded with mini chocolate chips.", items: "sugar 3 cup; canned_pumpkin 1 each; eggs 4 each; vegetable_oil 1 cup; flour 3.5 cup; cinnamon 1 tbsp; nutmeg 1 tbsp; baking_soda 2 tsp; salt 1.5 tsp; chocolate_chips 1 cup" },
{ id: "family-christmas-crack", title: "Christmas Crack", mins: 25, serves: 16, tools: ["stovetop", "oven"], styles: ["caydens_corner"], about: "Saltine toffee with chocolate and pecans. Dangerously addictive.", items: "saltines 4 oz; butter 1 cup; brown_sugar 1 cup; chocolate_chips 2 cup; pecans 0.75 cup" },
{ id: "family-cowboy-cookies", title: "Cowboy Cookies", mins: 30, serves: 18, tools: ["oven"], styles: ["caydens_corner"], about: "Chewy oatmeal chocolate chip cookies.", items: "flour 2 cup; baking_powder 1 tsp; baking_soda 1 tsp; salt 0.5 tsp; butter 1 cup; sugar 1 cup; brown_sugar 1 cup; eggs 2 each; vanilla_extract 1 tsp; oats 2 cup; chocolate_chips 1 cup" },
{ id: "family-cream-cheese-frosting", title: "Cream Cheese Frosting", mins: 10, serves: 12, tools: [], styles: ["caydens_corner", "quick"], about: "The frosting for banana bread, pumpkin bread, and pretty much everything.", tip: "Add extra vanilla if you like a stronger flavor.", items: "cream_cheese 16 oz; butter 0.5 cup; vanilla_extract 2 tsp; powdered_sugar 1 lb" },
{ id: "family-ginger-kringles", title: "Ginger Kringles", mins: 30, serves: 18, tools: ["oven"], styles: ["caydens_corner"], about: "Soft molasses ginger cookies rolled in sugar.", items: "vegetable_oil 0.66 cup; sugar 1.25 cup; eggs 1 each; molasses 4 tbsp; flour 2 cup; baking_soda 2 tsp; salt 0.5 tsp; cinnamon 1 tsp; ground_ginger 1.25 tsp" },
{ id: "family-million-dollar-fudge", title: "Million Dollar Fudge", mins: 30, serves: 36, tools: ["stovetop"], styles: ["caydens_corner"], about: "Rich chocolate marshmallow fudge with pecans (pecans are the bomb).", items: "sugar 4.5 cup; salt 0.125 tsp; butter 2 tbsp; evaporated_milk 1 each; pecans 2 cup; chocolate_chips 2 cup; german_chocolate 12 oz; marshmallow_creme 2 cup" },
{ id: "family-moms-banana-bread", title: "Mom's Banana Bread", mins: 90, serves: 12, tools: ["oven"], styles: ["caydens_corner", "comfort"], about: "Mom's moist buttermilk banana bread.", items: "sugar 1.5 cup; butter 0.5 cup; eggs 2 each; bananas 4 each; buttermilk 0.5 cup; vanilla_extract 1 tsp; flour 2.5 cup; baking_soda 1 tsp; salt 1 tsp" },
{ id: "family-ooey-gooey-bars", title: "Ooey Gooey Bars", mins: 40, serves: 24, tools: ["stovetop", "oven"], styles: ["caydens_corner"], about: "Oat crumble bars with caramel and two kinds of chocolate.", items: "oats 2 cup; flour 1 cup; brown_sugar 1 cup; salt 0.5 tsp; baking_soda 0.5 tsp; butter 0.75 cup; heavy_cream 9 tbsp; caramels 1 each; dark_chocolate_chips 0.75 cup; chocolate_chips 0.75 cup" },
{ id: "family-peppermint-chocolate-thumbprint-cookies", title: "Peppermint Chocolate Thumbprint Cookies", mins: 40, serves: 14, tools: ["oven"], styles: ["caydens_corner"], about: "Fudgy mint chocolate cookies crowned with a peppermint kiss.", items: "butter 0.5 cup; sugar 0.5 cup; brown_sugar 0.5 cup; eggs 2 each; mint_extract 1 tsp; flour 1.5 cup; cocoa_powder 0.66 cup; salt 0.25 tsp; baking_powder 0.25 tsp; baking_soda 0.25 tsp; peppermint_kisses 28 each" },
{ id: "family-soft-sugar-cookies", title: "Soft Sugar Cookies", mins: 150, serves: 24, tools: ["oven"], styles: ["caydens_corner"], about: "Cut-out sugar cookies with creamy frosting.", items: "flour 3 cup; baking_powder 1 tsp; salt 0.5 tsp; butter 1 cup; sugar 1.5 cup; eggs 2 each; vanilla_extract 3 tsp; powdered_sugar 4 cup; shortening 0.5 cup; milk 5 tbsp" },
]);

79
app/lib/recipes/dish.ts Normal file
View file

@ -0,0 +1,79 @@
import type { Appliance, CookingStyle, Cuisine, MealKind, RecipeData, RecipeIngredient, RecipeUnit } from "@/app/types";
// ---------------------------------------------------------------------------
// Compact recipe format, used for the big recipe library in ./extra/.
//
// Instead of a 12-line object per recipe, each dish is one entry with its
// ingredients written like a shopping list:
//
// { title: "Beef Stroganoff", mins: 30, tools: ["stovetop"], styles: ["comfort"],
// about: "Creamy mushroom sauce over egg noodles.",
// items: "ground_beef 1 lb; egg_noodles 12 oz; mushrooms 8 oz; sour_cream 0.5 cup" }
//
// Each ingredient is "<catalog item id> <amount> <unit>". Bad units throw at
// module load, so typos fail loudly in dev instead of producing $0 groceries.
// ---------------------------------------------------------------------------
export interface DishInput {
title: string;
/** Defaults to a slug of the title. Set it explicitly to keep saved plans stable if you rename. */
id?: string;
about: string;
mins: number;
tools: Appliance[];
styles?: CookingStyle[];
/** Defaults to the file's cuisine/kind (see `dishes`). */
cuisine?: Cuisine;
kind?: MealKind;
/** People one batch feeds. Default 4. */
serves?: number;
/** "item_id amount unit; item_id amount unit; …" */
items: string;
/** Optional cook's tip shown under the steps. */
tip?: string;
}
const UNITS = new Set<RecipeUnit>(["g", "oz", "lb", "ml", "tsp", "tbsp", "cup", "each"]);
export const slugify = (text: string) =>
text
.normalize("NFD") // "Étouffée" → "Etouffee" + combining accents…
.replace(/[\u0300-\u036f]/g, "") // …which this strips
.replace(/ı/g, "i") // Turkish dotless i (doesn't decompose)
.toLowerCase()
.replace(/['’]/g, "") // "Shepherd's" → "shepherds", not "shepherd-s"
.replace(/&/g, "and")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
function parseItems(items: string, title: string): RecipeIngredient[] {
return items
.split(";")
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const [itemId, amountText, unit] = part.split(/\s+/);
const amount = Number(amountText);
if (!itemId || !Number.isFinite(amount) || amount <= 0 || !UNITS.has(unit as RecipeUnit)) {
throw new Error(`Recipe "${title}": can't parse ingredient "${part}" (expected "item_id amount unit")`);
}
return { itemId, amount, unit: unit as RecipeUnit };
});
}
/** Build a list of recipes that share a default cuisine and meal kind. */
export function dishes(defaults: { cuisine: Cuisine; kind?: MealKind }, list: DishInput[]): RecipeData[] {
return list.map((d) => ({
id: d.id ?? slugify(d.title),
title: d.title,
description: d.about,
cuisine: d.cuisine ?? defaults.cuisine,
styles: d.styles ?? [],
kind: d.kind ?? defaults.kind ?? "main",
prepTime: d.mins,
requiredAppliances: d.tools,
servings: d.serves ?? 4,
ingredients: parseItems(d.items, d.title),
...(d.tip ? { tip: d.tip } : {}),
}));
}

View file

@ -0,0 +1,75 @@
import { dishes } from "../dish";
// Extra mains: American classics, then Southern & Cajun.
export const EXTRA_AMERICAN = dishes({ cuisine: "american" }, [
// --- Burgers, sandwiches & handhelds ----------------------------------------
{ title: "Smash Burgers", mins: 20, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Crispy-edged thin patties, American-style.", items: "ground_beef 1 lb; hamburger_buns 4 each; cheddar 0.5 cup; onion 0.5 each; ketchup 2 tbsp; mustard 1 tbsp" },
{ title: "Turkey Burgers & Oven Fries", mins: 35, tools: ["stovetop", "oven"], styles: ["healthy"], about: "Juicy turkey patties with crispy fries.", items: "ground_turkey 1 lb; hamburger_buns 4 each; frozen_fries 16 oz; romaine 1 each; mayo 2 tbsp; garlic_powder 1 tsp" },
{ title: "Sloppy Joes", mins: 20, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Sweet-tangy saucy beef on buns.", items: "ground_beef 1 lb; tomato_sauce 1 each; ketchup 0.25 cup; brown_sugar 1 tbsp; worcestershire 1 tbsp; onion 1 each; hamburger_buns 4 each" },
{ title: "Chili Dogs", mins: 20, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Franks smothered in quick beef chili.", items: "hot_dogs 8 each; hot_dog_buns 8 each; ground_beef 0.5 lb; tomato_sauce 1 each; chili_powder 1 tbsp; cheddar 0.5 cup; onion 0.5 each" },
{ title: "Philly Cheesesteak Hoagies", mins: 25, tools: ["stovetop"], styles: ["comfort"], about: "Thin-sliced beef, peppers, onions, melted cheese.", items: "beef_stew_meat 1 lb; hoagie_rolls 4 each; bell_pepper 1 each; onion 1 each; mozzarella 1 cup; worcestershire 1 tbsp" },
{ title: "Meatball Subs", mins: 25, tools: ["oven"], styles: ["comfort"], about: "Frozen meatballs, marinara, melty mozzarella.", items: "frozen_meatballs 16 oz; marinara 1 each; hoagie_rolls 4 each; mozzarella 1 cup" },
{ title: "Turkey Club Wraps", mins: 10, tools: [], styles: ["quick"], about: "No-cook turkey, bacon-free club in a wrap.", items: "flour_tortillas 4 each; deli_turkey 9 oz; romaine 1 each; roma_tomatoes 2 each; cheddar 0.5 cup; mayo 3 tbsp" },
{ title: "Buffalo Chicken Wraps", mins: 15, tools: [], styles: ["quick"], about: "Rotisserie chicken tossed in buffalo, ranch, crunch.", items: "rotisserie_chicken 2 cup; buffalo_sauce 0.25 cup; ranch 0.25 cup; flour_tortillas 4 each; romaine 1 each; celery 1 each" },
{ title: "Chicken Salad Crescent Sandwiches", mins: 15, tools: ["oven"], styles: ["quick"], about: "Creamy chicken salad on warm crescent rolls.", items: "rotisserie_chicken 2 cup; mayo 0.33 cup; celery 1 each; apples 1 each; crescent_rolls 8 each" },
{ title: "Grilled Ham & Cheese", mins: 15, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Golden buttery sandwiches.", items: "sandwich_bread 8 each; diced_ham 8 oz; cheddar 1.5 cup; butter 3 tbsp" },
{ title: "BBQ Chicken Sliders", mins: 15, tools: ["microwave"], styles: ["quick", "comfort"], about: "Shredded rotisserie chicken in BBQ sauce with slaw.", items: "rotisserie_chicken 2 cup; bbq_sauce 0.5 cup; hamburger_buns 4 each; coleslaw_mix 6 oz; mayo 2 tbsp" },
{ title: "Air Fryer Hot Dogs & Fries", mins: 20, tools: ["air_fryer"], styles: ["quick", "comfort"], about: "Snappy franks and crinkle fries, one basket.", items: "hot_dogs 8 each; hot_dog_buns 8 each; frozen_fries 16 oz; ketchup 3 tbsp; mustard 2 tbsp" },
// --- Casseroles & bakes -------------------------------------------------------------
{ title: "Chicken Pot Pie", mins: 50, tools: ["stovetop", "oven"], styles: ["comfort"], serves: 6, about: "Creamy chicken and veg under a biscuit top.", items: "rotisserie_chicken 3 cup; frozen_mixed_veg 12 oz; cream_of_chicken 2 each; milk 0.5 cup; biscuit_dough 8 each" },
{ title: "Tuna Noodle Casserole", mins: 40, tools: ["stovetop", "oven"], styles: ["comfort", "seafood"], serves: 6, about: "Retro, creamy, crunchy-topped.", items: "egg_noodles 12 oz; canned_tuna 2 each; cream_of_mushroom 2 each; frozen_peas 1 cup; cheddar 1 cup; panko 0.5 cup" },
{ title: "Hash Brown Hotdish", mins: 50, tools: ["oven", "stovetop"], styles: ["comfort"], serves: 6, about: "Minnesota church-basement classic.", items: "ground_beef 1 lb; cream_of_mushroom 2 each; frozen_green_beans 12 oz; frozen_hash_browns 20 oz; cheddar 1 cup; onion 1 each" },
{ title: "Chicken & Rice Casserole", mins: 60, tools: ["oven"], styles: ["comfort", "meal_prep"], serves: 6, about: "Dump, bake, done.", items: "chicken_thighs 1.5 lb; white_rice 1.5 cup; cream_of_chicken 2 each; chicken_broth 2 cup; frozen_broccoli 12 oz; cheddar 1 cup" },
{ title: "Broccoli Cheddar Chicken Bake", mins: 40, tools: ["oven"], styles: ["comfort"], about: "Cheesy, creamy, crowd-pleasing.", items: "chicken_breast 1.5 lb; frozen_broccoli 24 oz; cream_of_chicken 1 each; cheddar 1.5 cup; panko 0.5 cup" },
{ title: "Baked Mac & Cheese with Ham", mins: 45, tools: ["stovetop", "oven"], styles: ["comfort"], serves: 6, about: "Crusty top, creamy middle, smoky ham.", items: "elbow_macaroni 1 lb; cheddar 3 cup; milk 3 cup; butter 4 tbsp; flour 0.25 cup; diced_ham 8 oz; panko 0.5 cup" },
{ title: "Meatloaf & Mashed Potatoes", mins: 70, tools: ["oven", "stovetop"], styles: ["comfort"], serves: 6, about: "Glazed meatloaf, buttery mash.", items: "ground_beef 1.5 lb; breadcrumbs 0.75 cup; eggs 1 each; ketchup 0.5 cup; onion 1 each; potatoes 2.5 lb; butter 4 tbsp; milk 0.5 cup" },
{ title: "Shepherd's Pie", mins: 60, tools: ["stovetop", "oven"], styles: ["comfort", "meal_prep"], serves: 6, about: "Savory beef and veg under mashed potatoes.", items: "ground_beef 1 lb; frozen_mixed_veg 12 oz; tomato_paste 1 each; worcestershire 1 tbsp; chicken_broth 1 cup; potatoes 2 lb; butter 3 tbsp; milk 0.5 cup" },
{ title: "Pepperoni Pizza", mins: 25, tools: ["oven"], styles: ["comfort", "quick"], about: "Homemade pizza night for a fraction of delivery.", items: "pizza_dough 1 each; tomato_sauce 1 each; mozzarella 2 cup; pepperoni 3 oz; italian_seasoning 1 tsp" },
{ title: "BBQ Chicken Pizza", mins: 25, tools: ["oven"], styles: ["comfort"], about: "Smoky-sweet with red onion vibes.", items: "pizza_dough 1 each; bbq_sauce 0.5 cup; rotisserie_chicken 1.5 cup; mozzarella 2 cup; onion 0.5 each; cilantro 1 each" },
{ title: "Veggie Supreme Pizza", mins: 25, tools: ["oven"], styles: ["vegetarian", "comfort"], about: "Peppers, mushrooms, onion, olives optional.", items: "pizza_dough 1 each; tomato_sauce 1 each; mozzarella 2 cup; mushrooms 4 oz; bell_pepper 1 each; onion 0.5 each" },
{ title: "Pigs in a Blanket & Slaw", mins: 20, tools: ["oven"], styles: ["comfort", "quick"], about: "Party food as dinner. No regrets.", items: "hot_dogs 8 each; crescent_rolls 8 each; coleslaw_mix 8 oz; mayo 3 tbsp; mustard 1 tbsp" },
// --- Skillets, soups & roasts ---------------------------------------------------------
{ title: "Beef Stroganoff", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Creamy mushroom gravy over egg noodles.", items: "ground_beef 1 lb; egg_noodles 12 oz; mushrooms 8 oz; sour_cream 0.75 cup; chicken_broth 1.5 cup; onion 1 each; flour 2 tbsp" },
{ title: "Cheeseburger Pasta Skillet", mins: 25, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Homemade 'helper' in one pan.", items: "ground_beef 1 lb; elbow_macaroni 8 oz; milk 1 cup; chicken_broth 2 cup; cheddar 1.5 cup; ketchup 2 tbsp; mustard 1 tbsp" },
{ title: "Chicken Noodle Soup", mins: 35, tools: ["stovetop"], styles: ["comfort", "healthy", "meal_prep"], serves: 6, about: "The cure for everything.", items: "rotisserie_chicken 2 cup; egg_noodles 6 oz; chicken_broth 8 cup; carrots 0.5 lb; celery 1 each; onion 1 each" },
{ title: "Slow Cooker Beef Stew", mins: 20, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Fall-apart beef, potatoes and carrots.", items: "beef_stew_meat 2 lb; potatoes 1.5 lb; carrots 1 lb; onion 1 each; tomato_paste 1 each; chicken_broth 3 cup; worcestershire 1 tbsp; flour 3 tbsp" },
{ title: "Slow Cooker Mushroom Pork Chops", mins: 15, tools: ["slow_cooker"], styles: ["comfort"], about: "Chops braised in mushroom gravy.", items: "pork_chops 2 lb; cream_of_mushroom 2 each; onion 1 each; potatoes 1.5 lb; carrots 0.5 lb" },
{ title: "Pork Chops & Applesauce Skillet", mins: 25, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Seared chops with quick cinnamon apples.", items: "pork_chops 1.5 lb; apples 3 each; butter 2 tbsp; brown_sugar 1 tbsp; cinnamon 0.5 tsp; frozen_green_beans 12 oz" },
{ title: "Air Fryer Pork Chops & Green Beans", mins: 25, tools: ["air_fryer"], styles: ["healthy", "quick"], about: "Juicy chops, blistered beans.", items: "pork_chops 1.5 lb; frozen_green_beans 12 oz; garlic_powder 1 tsp; paprika 1 tsp; olive_oil 1 tbsp" },
{ title: "Honey Mustard Drumsticks", mins: 45, tools: ["oven"], styles: ["comfort", "meal_prep"], about: "Sticky, tangy, the cheapest chicken there is.", items: "chicken_drumsticks 3 lb; honey 0.33 cup; mustard 0.25 cup; garlic_powder 1 tsp; potatoes 1.5 lb" },
{ title: "Air Fryer Buffalo Drumsticks", mins: 30, tools: ["air_fryer"], styles: ["comfort"], about: "Wing-night flavor on a drumstick budget.", items: "chicken_drumsticks 3 lb; buffalo_sauce 0.5 cup; ranch 0.33 cup; celery 1 each; carrots 0.5 lb" },
{ title: "Slow Cooker Salsa Chicken", mins: 10, tools: ["slow_cooker"], styles: ["meal_prep", "healthy"], about: "Two ingredients, endless uses.", items: "chicken_breast 2 lb; salsa 1.5 cup; taco_seasoning 1 each; white_rice 1.5 cup" },
{ title: "Rotisserie Chicken Cobb Salad", mins: 15, tools: ["stovetop"], styles: ["healthy", "quick"], about: "Chicken, egg, bacon, avocado, ranch.", items: "rotisserie_chicken 2 cup; romaine 2 each; eggs 4 each; bacon 4 oz; avocado 1 each; roma_tomatoes 2 each; ranch 0.33 cup" },
{ title: "Microwave Chili Mac", mins: 15, tools: ["microwave"], styles: ["quick", "comfort"], about: "Dorm-room chili mac, actually good.", items: "elbow_macaroni 8 oz; kidney_beans 1 each; tomato_sauce 1 each; chili_powder 1 tbsp; cheddar 1 cup" },
{ title: "Rice Cooker Chicken & Broccoli Cheesy Rice", mins: 10, tools: ["rice_cooker"], styles: ["comfort", "quick"], about: "Like the boxed stuff, but real.", items: "white_rice 2 cup; chicken_broth 2.5 cup; chicken_breast 1 lb; frozen_broccoli 12 oz; cheddar 1 cup" },
{ title: "Blender Broccoli Cheddar Soup", mins: 30, tools: ["stovetop", "blender"], styles: ["comfort", "vegetarian"], about: "Panera-style, silky smooth.", items: "frozen_broccoli 24 oz; cheddar 2 cup; milk 2 cup; chicken_broth 2 cup; onion 1 each; butter 3 tbsp; flour 3 tbsp" },
{ title: "Loaded Potato Soup", mins: 35, tools: ["stovetop"], styles: ["comfort"], serves: 6, about: "Bacon, cheddar, sour cream, green onions.", items: "potatoes 3 lb; chicken_broth 4 cup; milk 1 cup; bacon 6 oz; cheddar 1 cup; sour_cream 0.5 cup; green_onions 1 each" },
// --- Pantry shortcuts ----------------------------------------------------------
{ title: "Canned Chicken Salad Wraps", mins: 10, tools: [], styles: ["quick"], about: "Shelf-stable protein, zero cooking.", items: "canned_chicken 2 each; mayo 0.25 cup; celery 1 each; apples 1 each; flour_tortillas 4 each; romaine 1 each" },
{ title: "Microwave Buffalo Chicken Rice Bowls", mins: 10, tools: ["microwave"], styles: ["quick"], about: "Canned chicken, buffalo, ranch, rice.", items: "canned_chicken 2 each; buffalo_sauce 0.25 cup; ranch 0.25 cup; white_rice 1.5 cup; celery 1 each; cheddar 0.5 cup" },
{ title: "Italian Pasta Salad", cuisine: "italian", mins: 20, tools: ["stovetop"], styles: ["meal_prep", "quick"], about: "Cold rotini-style salad with pepperoni and veg.", items: "penne 1 lb; italian_dressing 0.75 cup; pepperoni 3 oz; mozzarella 1 cup; cucumber 1 each; roma_tomatoes 3 each; bell_pepper 1 each" },
{ title: "Italian Dressing Chicken & Veggies", cuisine: "italian", mins: 35, tools: ["oven"], styles: ["healthy", "meal_prep"], about: "The dressing is the marinade. That's it.", items: "chicken_breast 1.5 lb; italian_dressing 0.5 cup; zucchini 2 each; potatoes 1 lb; parmesan 0.25 cup" },
{ title: "Cauliflower Fried Rice", cuisine: "chinese", mins: 15, tools: ["stovetop"], styles: ["healthy", "quick", "vegetarian"], about: "Low-carb fried rice, all the flavor.", items: "cauliflower_rice 24 oz; eggs 3 each; frozen_mixed_veg 8 oz; soy_sauce 3 tbsp; sesame_oil 1 tsp; green_onions 1 each" },
{ title: "Cauliflower Rice Burrito Bowls", cuisine: "mexican", mins: 20, tools: ["stovetop"], styles: ["healthy"], about: "Lighter burrito bowls on cauliflower rice.", items: "cauliflower_rice 24 oz; ground_turkey 1 lb; taco_seasoning 1 each; black_beans 1 each; salsa 0.5 cup; avocado 1 each" },
{ title: "Quinoa Power Bowls", mins: 25, tools: ["stovetop", "oven"], styles: ["healthy", "vegetarian", "meal_prep"], about: "Roasted sweet potato, chickpeas, greens, tahini-free dressing.", items: "quinoa 1.5 cup; sweet_potatoes 2 each; chickpeas 1 each; spinach 3 oz; lemon 1 each; olive_oil 2 tbsp; greek_yogurt 0.5 cup" },
]);
export const EXTRA_SOUTHERN = dishes({ cuisine: "southern" }, [
{ title: "Oven-Fried Chicken Drumsticks", mins: 50, tools: ["oven"], styles: ["comfort"], about: "Crunchy panko crust, no frying.", items: "chicken_drumsticks 3 lb; panko 1.5 cup; eggs 2 each; paprika 2 tsp; garlic_powder 1 tsp; hot_sauce 2 tbsp" },
{ title: "Chicken & Dumplings", mins: 40, tools: ["stovetop"], styles: ["comfort"], serves: 6, about: "Fluffy biscuit dumplings in creamy chicken stew.", items: "rotisserie_chicken 3 cup; chicken_broth 6 cup; biscuit_dough 8 each; carrots 0.5 lb; celery 1 each; cream_of_chicken 1 each" },
{ title: "Gumbo", mins: 75, tools: ["stovetop"], styles: ["comfort", "meal_prep"], serves: 6, about: "Dark roux, chicken, sausage, rice.", items: "chicken_thighs 1.5 lb; smoked_sausage 13 oz; flour 0.5 cup; vegetable_oil 0.5 cup; onion 1 each; bell_pepper 1 each; celery 1 each; chicken_broth 6 cup; cajun_seasoning 1 tbsp; white_rice 1.5 cup" },
{ title: "Dirty Rice", mins: 35, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Cajun rice with ground pork and the holy trinity.", items: "ground_pork 1 lb; white_rice 1.5 cup; chicken_broth 3 cup; onion 1 each; bell_pepper 1 each; celery 1 each; cajun_seasoning 1 tbsp" },
{ title: "Nashville Hot Chicken Sandwiches", mins: 35, tools: ["air_fryer"], styles: ["comfort"], about: "Fiery cayenne-glazed crispy chicken, pickles optional.", items: "chicken_breast 1.5 lb; panko 1 cup; eggs 2 each; hot_sauce 0.25 cup; paprika 1 tbsp; brown_sugar 1 tbsp; hamburger_buns 4 each; coleslaw_mix 6 oz" },
{ title: "Slow Cooker Pulled Chicken & Cornbread", mins: 40, tools: ["slow_cooker", "oven"], styles: ["comfort", "meal_prep"], serves: 6, about: "BBQ shredded chicken with sweet cornbread.", items: "chicken_thighs 2 lb; bbq_sauce 1 cup; cornbread_mix 2 each; eggs 2 each; milk 0.66 cup; coleslaw_mix 8 oz" },
{ title: "Hoppin' John", mins: 35, tools: ["stovetop"], styles: ["comfort"], about: "Beans, rice and smoky ham for good luck.", items: "pinto_beans 2 each; diced_ham 8 oz; white_rice 1.5 cup; onion 1 each; celery 1 each; chicken_broth 3 cup; cajun_seasoning 1 tsp" },
{ title: "Chicken & Sausage Jambalaya Pasta", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Rasta-pasta-style creamy Cajun penne.", items: "penne 1 lb; chicken_breast 1 lb; smoked_sausage 6.5 oz; heavy_cream 1 cup; cajun_seasoning 1 tbsp; bell_pepper 1 each; parmesan 0.5 cup" },
{ 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" },
{ title: "Rice Cooker Red Beans & Sausage", mins: 10, tools: ["rice_cooker"], styles: ["comfort", "quick"], about: "Weeknight shortcut to a Monday classic.", items: "white_rice 2 cup; kidney_beans 1 each; smoked_sausage 6.5 oz; chicken_broth 2.5 cup; cajun_seasoning 2 tsp; onion 0.5 each" },
]);

View file

@ -0,0 +1,46 @@
import { dishes } from "../dish";
// Extra mains: Mexican, then Caribbean.
export const EXTRA_MEXICAN = dishes({ cuisine: "mexican" }, [
{ title: "Chicken Tinga Tacos", mins: 25, tools: ["stovetop"], styles: ["comfort"], about: "Shredded chicken in smoky chipotle-tomato sauce.", items: "rotisserie_chicken 3 cup; chipotle_adobo 0.5 each; tomato_sauce 1 each; onion 1 each; corn_tortillas 12 each; sour_cream 0.33 cup" },
{ title: "Carnitas", mins: 20, tools: ["slow_cooker", "oven"], styles: ["comfort", "meal_prep"], serves: 6, about: "Slow-cooked pork crisped under the broiler.", items: "pork_shoulder 3 lb; orange_juice 0.5 cup; lime 2 each; cumin 2 tsp; onion 1 each; corn_tortillas 18 each; cilantro 1 each" },
{ title: "Pork Chile Verde", mins: 20, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Tender pork in tangy tomatillo salsa.", items: "pork_shoulder 3 lb; salsa_verde 2 cup; green_chiles 1 each; onion 1 each; cumin 2 tsp; white_rice 2 cup; flour_tortillas 6 each" },
{ title: "Beef Barbacoa Bowls", mins: 20, tools: ["slow_cooker"], styles: ["meal_prep"], serves: 6, about: "Chipotle-spiced shredded beef over rice.", items: "beef_stew_meat 2 lb; chipotle_adobo 1 each; lime 2 each; cumin 2 tsp; chicken_broth 1 cup; white_rice 2 cup; black_beans 2 each" },
{ title: "Sheet-Pan Chicken Fajitas", mins: 30, tools: ["oven"], styles: ["healthy", "quick"], about: "Peppers, onions and spiced chicken on one pan.", items: "chicken_breast 1.5 lb; bell_pepper 3 each; onion 1 each; taco_seasoning 1 each; olive_oil 2 tbsp; flour_tortillas 8 each; sour_cream 0.33 cup" },
{ title: "Beef Fajitas", mins: 25, tools: ["stovetop"], styles: ["quick"], about: "Seared beef strips with charred peppers.", items: "beef_stew_meat 1.5 lb; bell_pepper 2 each; onion 1 each; lime 1 each; cumin 1 tsp; chili_powder 1 tsp; flour_tortillas 8 each" },
{ title: "Bean & Cheese Burritos", mins: 15, tools: ["microwave"], styles: ["vegetarian", "quick", "meal_prep"], about: "Freezer burritos for pennies.", items: "flour_tortillas 8 each; refried_beans 2 each; cheddar 2 cup; salsa 0.5 cup" },
{ title: "Beef & Bean Burritos", mins: 25, tools: ["stovetop"], styles: ["comfort", "meal_prep"], about: "Wrap a dozen, freeze half.", items: "ground_beef 1 lb; refried_beans 1 each; taco_seasoning 1 each; flour_tortillas 8 each; cheddar 1.5 cup; white_rice 1 cup" },
{ title: "Loaded Sheet-Pan Nachos", mins: 20, tools: ["oven"], styles: ["comfort", "quick"], about: "Chips, beef, beans, cheese; dinner, apparently.", items: "tortilla_chips 13 oz; ground_beef 1 lb; taco_seasoning 1 each; black_beans 1 each; cheddar 2 cup; salsa 0.5 cup; sour_cream 0.5 cup; jalapeno 1 each" },
{ title: "Chicken Quesadillas", mins: 20, tools: ["stovetop"], styles: ["quick", "comfort"], about: "Rotisserie chicken, peppers, melty cheese.", items: "rotisserie_chicken 2 cup; flour_tortillas 8 each; cheddar 2 cup; bell_pepper 1 each; salsa 0.5 cup" },
{ title: "Air Fryer Chicken Taquitos", mins: 25, tools: ["air_fryer"], styles: ["comfort"], about: "Crispy rolled tacos with cream cheese chicken.", items: "rotisserie_chicken 2 cup; cream_cheese 4 oz; salsa_verde 0.5 cup; corn_tortillas 16 each; cheddar 1 cup" },
{ title: "Green Chicken Enchiladas", mins: 45, tools: ["oven"], styles: ["comfort", "meal_prep"], about: "Salsa verde and sour cream sauce.", items: "rotisserie_chicken 3 cup; salsa_verde 1.5 cup; sour_cream 0.5 cup; corn_tortillas 12 each; mozzarella 1.5 cup; green_chiles 1 each" },
{ title: "Black Bean Enchiladas", mins: 40, tools: ["oven"], styles: ["vegetarian", "meal_prep"], about: "Bean, corn and cheese enchiladas in red sauce.", items: "black_beans 2 each; frozen_corn 6 oz; enchilada_sauce 2 each; flour_tortillas 8 each; cheddar 2 cup; onion 0.5 each" },
{ title: "Tortilla Soup", mins: 30, tools: ["stovetop"], styles: ["comfort", "healthy"], serves: 6, about: "Brothy, smoky, crunchy-topped.", items: "rotisserie_chicken 2 cup; chicken_broth 6 cup; diced_tomatoes 1 each; black_beans 1 each; frozen_corn 6 oz; chipotle_adobo 0.25 each; tortilla_chips 4 oz; lime 1 each" },
{ title: "Red Chile Pork & Bean Stew", mins: 20, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Red chile pork stew with cabbage on top.", items: "pork_shoulder 2 lb; enchilada_sauce 2 each; chicken_broth 4 cup; white_beans 2 each; onion 1 each; cabbage 0.5 each; lime 2 each" },
{ title: "Taco Rice Skillet", mins: 25, tools: ["stovetop"], styles: ["quick", "comfort"], about: "One-pan taco meat and rice.", items: "ground_beef 1 lb; white_rice 1.5 cup; tomato_sauce 1 each; taco_seasoning 1 each; chicken_broth 2.5 cup; cheddar 1 cup" },
{ title: "Mexican Street Corn Chicken Bowls", mins: 25, tools: ["stovetop"], styles: ["healthy"], about: "Elote-style corn over chicken and rice.", items: "chicken_breast 1.5 lb; frozen_corn 12 oz; mayo 2 tbsp; lime 1 each; chili_powder 1 tsp; parmesan 0.25 cup; white_rice 1.5 cup" },
{ title: "Chorizo & Potato Tacos", mins: 25, tools: ["stovetop"], styles: ["comfort"], about: "Crispy potatoes in spicy chorizo.", items: "chorizo 9 oz; potatoes 1 lb; corn_tortillas 12 each; onion 0.5 each; cilantro 1 each; salsa_verde 0.5 cup" },
{ title: "Turkey Taco Lettuce Cups", mins: 15, tools: ["stovetop"], styles: ["healthy", "quick"], about: "Low-carb tacos in romaine boats.", items: "ground_turkey 1 lb; taco_seasoning 1 each; romaine 2 each; roma_tomatoes 2 each; cheddar 0.5 cup; salsa 0.5 cup" },
{ title: "Instant Mexican Rice & Beans", mins: 10, tools: ["microwave"], styles: ["vegetarian", "quick"], about: "Microwave rice with salsa, beans and cheese.", items: "white_rice 1.5 cup; pinto_beans 1 each; salsa 0.75 cup; cheddar 0.75 cup; cumin 1 tsp" },
{ title: "Slow Cooker Chicken Burrito Filling", mins: 10, tools: ["slow_cooker"], styles: ["meal_prep", "healthy"], serves: 6, about: "Chicken, beans, corn and salsa: fill anything.", items: "chicken_breast 2 lb; black_beans 2 each; frozen_corn 12 oz; salsa 1.5 cup; taco_seasoning 1 each; flour_tortillas 10 each" },
{ title: "Chicken Tostadas", mins: 20, tools: ["oven"], styles: ["quick"], about: "Crispy baked corn tortillas piled high.", items: "corn_tortillas 12 each; refried_beans 1 each; rotisserie_chicken 2 cup; romaine 1 each; salsa 0.5 cup; sour_cream 0.33 cup" },
{ title: "Black Bean Chilaquiles Bake", mins: 30, tools: ["oven"], styles: ["vegetarian", "comfort"], about: "Chips baked in sauce with beans and cheese.", items: "tortilla_chips 10 oz; enchilada_sauce 2 each; black_beans 1 each; cheddar 1.5 cup; eggs 4 each" },
{ title: "Rice Cooker Arroz con Pollo", mins: 10, tools: ["rice_cooker"], styles: ["comfort", "quick"], about: "Saffron-free but full of flavor.", items: "white_rice 2 cup; chicken_thighs 1 lb; tomato_sauce 1 each; chicken_broth 2 cup; frozen_peas 1 cup; cumin 1 tsp; onion 0.5 each" },
{ title: "Blender Salsa & Chicken Nacho Bowls", mins: 15, tools: ["blender", "microwave"], styles: ["quick"], about: "Fresh blender salsa over chicken, rice and chips.", items: "roma_tomatoes 5 each; onion 0.5 each; jalapeno 1 each; cilantro 1 each; lime 1 each; rotisserie_chicken 2 cup; white_rice 1.5 cup; tortilla_chips 4 oz" },
]);
export const EXTRA_CARIBBEAN = dishes({ cuisine: "caribbean" }, [
{ title: "Cuban Black Beans & Rice", mins: 30, tools: ["stovetop"], styles: ["vegetarian", "comfort"], about: "Moros-style beans with sofrito.", items: "black_beans 3 each; white_rice 1.5 cup; onion 1 each; bell_pepper 1 each; cumin 2 tsp; minced_garlic 1 tbsp; apple_cider_vinegar 1 tbsp" },
{ title: "Cuban Mojo Pork Bowls", mins: 20, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Citrus-garlic pork, black beans, rice.", items: "pork_shoulder 3 lb; orange_juice 1 cup; lime 2 each; minced_garlic 2 tbsp; cumin 2 tsp; white_rice 2 cup; black_beans 2 each" },
{ title: "Cuban-Style Pressed Sandwiches", mins: 20, tools: ["stovetop"], styles: ["comfort"], about: "Ham, pork, mustard, cheese; pressed crispy.", items: "hoagie_rolls 4 each; diced_ham 8 oz; deli_turkey 4 oz; mozzarella 1 cup; mustard 2 tbsp; butter 2 tbsp" },
{ title: "Jerk Pork Chops with Pineapple", mins: 25, tools: ["stovetop"], styles: ["quick"], about: "Spicy chops, sweet caramelized pineapple.", items: "pork_chops 1.5 lb; jerk_seasoning 1 tbsp; pineapple 1 each; white_rice 1.5 cup; green_onions 1 each" },
{ title: "Air Fryer Jerk Drumsticks", mins: 30, tools: ["air_fryer"], styles: ["comfort"], about: "Crispy-skinned, fiery, cheap.", items: "chicken_drumsticks 3 lb; jerk_seasoning 2 tbsp; lime 1 each; brown_sugar 1 tbsp; white_rice 1.5 cup" },
{ title: "Caribbean Coconut Curry Chicken", mins: 40, tools: ["stovetop"], styles: ["comfort"], about: "Island curry with potatoes.", items: "chicken_thighs 1.5 lb; coconut_milk 400 ml; curry_powder 2 tbsp; potatoes 1 lb; onion 1 each; jerk_seasoning 1 tsp; white_rice 1.5 cup" },
{ title: "Sofrito Rice & Beans", mins: 40, tools: ["stovetop"], styles: ["comfort"], about: "Sofrito rice with beans and ham.", items: "white_rice 2 cup; pinto_beans 1 each; diced_ham 4 oz; tomato_sauce 1 each; bell_pepper 1 each; onion 1 each; chicken_broth 3 cup" },
{ title: "Picadillo", mins: 25, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Cuban ground beef hash with potatoes, over rice.", items: "ground_beef 1 lb; potatoes 0.5 lb; tomato_sauce 1 each; onion 1 each; bell_pepper 1 each; cumin 1 tsp; white_rice 1.5 cup" },
{ title: "Tostones & Black Bean Bowls", mins: 30, tools: ["stovetop"], styles: ["vegetarian"], about: "Twice-fried plantains with beans and rice.", items: "plantains 4 each; black_beans 2 each; white_rice 1.5 cup; vegetable_oil 0.33 cup; lime 1 each; minced_garlic 1 tsp" },
{ title: "Jamaican Beef Patty Hand Pies", mins: 40, tools: ["oven", "stovetop"], styles: ["comfort"], about: "Curried beef tucked into flaky crescent dough.", items: "ground_beef 1 lb; crescent_rolls 16 each; curry_powder 1 tbsp; onion 1 each; breadcrumbs 0.25 cup; jerk_seasoning 1 tsp" },
{ title: "Rice Cooker Coconut Rice & Peas", mins: 10, tools: ["rice_cooker"], styles: ["vegetarian", "quick"], about: "Coconut rice with kidney beans and thyme vibes.", items: "white_rice 2 cup; coconut_milk 400 ml; kidney_beans 1 each; green_onions 1 each; jerk_seasoning 0.5 tsp" },
{ title: "Slow Cooker Caribbean Brown Stew Beef", mins: 20, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Rich brown-stew beef with butter beans.", items: "beef_stew_meat 2 lb; white_beans 2 each; onion 1 each; carrots 0.5 lb; brown_sugar 2 tbsp; soy_sauce 2 tbsp; jerk_seasoning 1 tbsp; white_rice 2 cup" },
]);

View file

@ -0,0 +1,86 @@
import { dishes } from "../dish";
// Extra mains: Indian, Chinese, Japanese, Korean, Thai.
export const EXTRA_INDIAN = dishes({ cuisine: "indian" }, [
{ title: "Chicken Tikka Masala", mins: 40, tools: ["stovetop"], styles: ["comfort"], about: "Creamy spiced tomato sauce, takeout favorite.", items: "chicken_thighs 1.5 lb; greek_yogurt 0.5 cup; tomato_sauce 2 each; heavy_cream 0.5 cup; curry_powder 2 tbsp; ginger_paste 1 tbsp; minced_garlic 1 tbsp; white_rice 1.5 cup; naan 4 each" },
{ title: "Chana Masala", mins: 30, tools: ["stovetop"], styles: ["vegetarian", "healthy", "meal_prep"], about: "Tangy spiced chickpeas in tomato gravy.", items: "chickpeas 3 each; crushed_tomatoes 1 each; onion 1 each; curry_powder 2 tbsp; ginger_paste 1 tbsp; lemon 1 each; white_rice 1.5 cup" },
{ title: "Aloo Matar (Potatoes & Peas)", mins: 30, tools: ["stovetop"], styles: ["vegetarian", "healthy"], about: "Turmeric-yellow potatoes with peas.", items: "potatoes 1.5 lb; frozen_peas 1.5 cup; onion 1 each; curry_powder 1 tbsp; cumin 1 tsp; vegetable_oil 3 tbsp; naan 4 each" },
{ title: "Keema Matar", mins: 30, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Spiced ground meat with peas.", items: "ground_turkey 1 lb; frozen_peas 1.5 cup; onion 1 each; diced_tomatoes 1 each; curry_powder 2 tbsp; ginger_paste 1 tsp; white_rice 1.5 cup" },
{ title: "Spinach & Ricotta Curry", mins: 30, tools: ["stovetop", "blender"], styles: ["vegetarian"], about: "Blended spinach curry with baked ricotta cubes.", items: "spinach 10 oz; ricotta 1.5 cup; heavy_cream 0.33 cup; onion 1 each; curry_powder 1 tbsp; ginger_paste 1 tsp; naan 4 each" },
{ title: "Slow Cooker Chicken Korma", mins: 15, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], serves: 6, about: "Mild, creamy, coconut-cashew-free korma.", items: "chicken_thighs 2 lb; coconut_milk 400 ml; greek_yogurt 0.5 cup; curry_powder 2 tbsp; onion 1 each; ginger_paste 1 tbsp; white_rice 2 cup" },
{ title: "Tandoori Drumsticks", mins: 45, tools: ["oven"], styles: ["healthy"], about: "Yogurt-marinated, charred, juicy.", items: "chicken_drumsticks 3 lb; greek_yogurt 1 cup; curry_powder 2 tbsp; paprika 1 tbsp; lemon 1 each; white_rice 1.5 cup; cucumber 1 each" },
{ title: "Egg Curry", mins: 30, tools: ["stovetop"], styles: ["vegetarian", "comfort"], about: "Hard-boiled eggs in spiced onion-tomato gravy.", items: "eggs 8 each; onion 2 each; diced_tomatoes 1 each; curry_powder 2 tbsp; coconut_milk 200 ml; white_rice 1.5 cup" },
{ title: "Vegetable Biryani", mins: 15, tools: ["rice_cooker"], styles: ["vegetarian", "meal_prep"], about: "Spiced rice and veg in the rice cooker.", items: "white_rice 2 cup; frozen_mixed_veg 12 oz; onion 1 each; curry_powder 1 tbsp; cinnamon 0.5 tsp; greek_yogurt 0.5 cup; butter 2 tbsp" },
{ title: "Chicken Biryani", mins: 15, tools: ["rice_cooker"], styles: ["comfort"], about: "Layered spiced chicken and rice, one pot.", items: "white_rice 2 cup; chicken_thighs 1 lb; greek_yogurt 0.5 cup; onion 1 each; curry_powder 2 tbsp; chicken_broth 2.5 cup" },
{ title: "Dal Tadka with Naan", mins: 30, tools: ["stovetop"], styles: ["vegetarian", "healthy"], about: "Yellow lentils finished with sizzling spiced butter.", items: "red_lentils 1.5 cup; butter 3 tbsp; cumin 1 tsp; minced_garlic 1 tbsp; diced_tomatoes 1 each; red_pepper_flakes 0.5 tsp; naan 4 each" },
{ title: "Microwave Masala Instant Noodles", mins: 8, tools: ["microwave"], styles: ["quick", "vegetarian"], about: "Maggi-style noodles with peas and spice.", items: "instant_ramen 4 each; frozen_peas 1 cup; curry_powder 2 tsp; ketchup 2 tbsp; onion 0.5 each" },
{ title: "Butter Chickpeas", mins: 25, tools: ["stovetop"], styles: ["vegetarian", "comfort", "quick"], about: "Butter chicken sauce, chickpeas instead.", items: "chickpeas 3 each; tomato_paste 1 each; heavy_cream 0.5 cup; butter 3 tbsp; curry_powder 2 tbsp; ginger_paste 1 tsp; white_rice 1.5 cup" },
{ title: "Samosa Hand Pies", mins: 40, tools: ["oven", "stovetop"], styles: ["vegetarian", "comfort"], about: "Spiced potato and pea filling in crescent dough.", items: "potatoes 1.5 lb; frozen_peas 1 cup; crescent_rolls 16 each; curry_powder 1 tbsp; onion 1 each" },
]);
export const EXTRA_CHINESE = dishes({ cuisine: "chinese" }, [
{ title: "Orange Chicken", mins: 35, tools: ["air_fryer", "stovetop"], styles: ["comfort"], about: "Crispy chicken in sticky orange glaze.", items: "chicken_breast 1.5 lb; cornstarch 0.5 cup; orange_juice 0.75 cup; soy_sauce 3 tbsp; brown_sugar 3 tbsp; rice_vinegar 1 tbsp; white_rice 1.5 cup" },
{ title: "General Tso's Chicken", mins: 35, tools: ["stovetop"], styles: ["comfort"], about: "Sweet, spicy, crispy, over rice.", items: "chicken_thighs 1.5 lb; cornstarch 0.5 cup; hoisin 3 tbsp; soy_sauce 3 tbsp; rice_vinegar 2 tbsp; sugar 2 tbsp; red_pepper_flakes 1 tsp; white_rice 1.5 cup; vegetable_oil 0.25 cup" },
{ title: "Sweet & Sour Pork", mins: 35, tools: ["stovetop"], styles: ["comfort"], about: "Crispy pork, pineapple, peppers, tangy sauce.", items: "pork_chops 1.5 lb; cornstarch 0.5 cup; pineapple 1 each; bell_pepper 2 each; ketchup 0.33 cup; rice_vinegar 3 tbsp; brown_sugar 3 tbsp; white_rice 1.5 cup" },
{ title: "Mapo Tofu", mins: 25, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Silky tofu in spicy pork-chili sauce.", items: "tofu 28 oz; ground_pork 0.5 lb; gochujang 2 tbsp; soy_sauce 2 tbsp; chicken_broth 1 cup; cornstarch 1 tbsp; green_onions 1 each; white_rice 1.5 cup" },
{ title: "Egg Drop Soup & Potstickers", mins: 20, tools: ["stovetop"], styles: ["quick", "comfort"], about: "Takeout night: silky soup and pan-fried dumplings.", items: "chicken_broth 6 cup; eggs 4 each; cornstarch 2 tbsp; green_onions 1 each; potstickers 20 oz; soy_sauce 2 tbsp" },
{ title: "Air Fryer Potsticker Bowls", mins: 15, tools: ["air_fryer", "microwave"], styles: ["quick"], about: "Crispy dumplings over rice with slaw.", items: "potstickers 20 oz; white_rice 1.5 cup; coleslaw_mix 8 oz; soy_sauce 3 tbsp; rice_vinegar 1 tbsp; sesame_oil 1 tsp" },
{ title: "Chicken & Peanut Stir-Fry", mins: 25, tools: ["stovetop"], styles: ["quick"], about: "Stir-fried chicken, peppers and roasted peanuts.", items: "chicken_breast 1.5 lb; peanuts 0.5 cup; bell_pepper 2 each; hoisin 2 tbsp; soy_sauce 2 tbsp; minced_garlic 1 tbsp; white_rice 1.5 cup" },
{ title: "Chicken Chow Mein", mins: 25, tools: ["stovetop"], styles: ["quick", "comfort"], about: "Noodles, cabbage, chicken, oyster-soy sauce.", items: "spaghetti 12 oz; chicken_breast 1 lb; cabbage 0.5 each; carrots 0.5 lb; oyster_sauce 3 tbsp; soy_sauce 2 tbsp; sesame_oil 1 tbsp" },
{ title: "Beef Lo Mein", mins: 25, tools: ["stovetop"], styles: ["comfort"], about: "Saucy noodles with seared beef strips.", items: "spaghetti 12 oz; beef_stew_meat 1 lb; mushrooms 8 oz; coleslaw_mix 6 oz; oyster_sauce 3 tbsp; soy_sauce 2 tbsp; brown_sugar 1 tbsp" },
{ title: "Moo Shu Pork Wraps", mins: 25, tools: ["stovetop"], styles: ["quick"], about: "Hoisin pork and cabbage in tortillas.", items: "ground_pork 1 lb; coleslaw_mix 14 oz; eggs 2 each; hoisin 0.25 cup; soy_sauce 2 tbsp; flour_tortillas 8 each; green_onions 1 each" },
{ title: "Hot & Sour Soup", mins: 25, tools: ["stovetop"], styles: ["healthy", "comfort"], about: "Tangy-peppery soup with tofu and mushrooms.", items: "chicken_broth 6 cup; tofu 14 oz; mushrooms 8 oz; eggs 2 each; rice_vinegar 0.25 cup; soy_sauce 3 tbsp; black_pepper 1 tsp; cornstarch 2 tbsp" },
{ title: "Garlic Green Beans & Pork Rice Bowls", mins: 20, tools: ["stovetop"], styles: ["quick"], about: "Dry-fried green beans with savory pork.", items: "frozen_green_beans 24 oz; ground_pork 0.5 lb; minced_garlic 1 tbsp; soy_sauce 2 tbsp; oyster_sauce 1 tbsp; white_rice 1.5 cup" },
{ title: "Slow Cooker Mongolian Beef", mins: 15, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], about: "Sweet-savory tender beef over rice.", items: "beef_stew_meat 2 lb; soy_sauce 0.5 cup; brown_sugar 0.33 cup; minced_garlic 1 tbsp; ginger_paste 1 tsp; cornstarch 2 tbsp; white_rice 2 cup; green_onions 1 each" },
{ title: "Rice Cooker Sweet Sausage Rice", mins: 10, tools: ["rice_cooker"], styles: ["comfort", "quick"], about: "Sweet-savory sausage steamed over rice.", items: "white_rice 2 cup; smoked_sausage 6.5 oz; soy_sauce 2 tbsp; brown_sugar 1 tbsp; frozen_peas 1 cup; green_onions 1 each" },
{ title: "Microwave Egg Fried Rice", mins: 10, tools: ["microwave"], styles: ["quick", "vegetarian"], about: "Leftover-rice fried rice, nuked.", items: "white_rice 2 cup; eggs 4 each; frozen_mixed_veg 8 oz; soy_sauce 3 tbsp; sesame_oil 1 tsp; green_onions 1 each" },
{ title: "Honey Shrimp", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort"], about: "Crispy shrimp in creamy honey sauce.", items: "shrimp 1 lb; cornstarch 0.33 cup; mayo 3 tbsp; honey 2 tbsp; lemon 1 each; white_rice 1.5 cup; frozen_broccoli 12 oz" },
]);
export const EXTRA_JAPANESE = dishes({ cuisine: "japanese" }, [
{ title: "Chicken Katsu Curry", mins: 40, tools: ["air_fryer", "stovetop"], styles: ["comfort"], about: "Crispy panko cutlet over Japanese curry.", items: "chicken_breast 1.5 lb; panko 1.5 cup; eggs 1 each; curry_powder 2 tbsp; flour 3 tbsp; butter 3 tbsp; potatoes 1 lb; carrots 0.5 lb; chicken_broth 3 cup; white_rice 1.5 cup" },
{ title: "Tonkatsu Pork Cutlets", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Crispy pork with tangy sauce and cabbage.", items: "pork_chops 1.5 lb; panko 1.5 cup; eggs 1 each; flour 0.25 cup; cabbage 0.5 each; ketchup 3 tbsp; worcestershire 2 tbsp; vegetable_oil 0.5 cup; white_rice 1.5 cup" },
{ title: "Gyudon (Beef & Onion Bowl)", mins: 20, tools: ["stovetop"], styles: ["quick", "comfort"], about: "Thin beef simmered sweet-savory over rice.", items: "beef_stew_meat 1 lb; onion 2 each; soy_sauce 3 tbsp; brown_sugar 2 tbsp; chicken_broth 1 cup; ginger_paste 1 tsp; white_rice 1.5 cup" },
{ title: "Miso Soup & Salmon Rice Bowls", mins: 20, tools: ["stovetop"], styles: ["seafood", "healthy", "quick"], about: "Flaked canned salmon, rice, quick miso soup.", items: "canned_salmon 1 each; white_rice 1.5 cup; miso 3 tbsp; tofu 14 oz; green_onions 1 each; seaweed_snacks 2 each" },
{ title: "Miso-Glazed Salmon", mins: 20, tools: ["oven"], styles: ["seafood", "healthy"], about: "Sweet-savory caramelized salmon.", items: "salmon 1.5 lb; miso 3 tbsp; honey 1 tbsp; soy_sauce 1 tbsp; white_rice 1.5 cup; frozen_broccoli 12 oz" },
{ title: "Yakisoba Stir-Fried Noodles", mins: 20, tools: ["stovetop"], styles: ["quick"], about: "Thick noodles, cabbage, pork, savory sauce.", items: "spaghetti 12 oz; ground_pork 0.5 lb; cabbage 0.5 each; carrots 0.5 lb; soy_sauce 3 tbsp; oyster_sauce 1 tbsp; worcestershire 1 tbsp" },
{ title: "Japanese Hamburg Steak", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Juicy beef patties in ketchup-worcestershire gravy.", items: "ground_beef 1 lb; panko 0.5 cup; eggs 1 each; onion 1 each; ketchup 0.25 cup; worcestershire 2 tbsp; white_rice 1.5 cup" },
{ title: "Okonomiyaki (Cabbage Pancakes)", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Savory cabbage pancakes with bacon and mayo.", items: "cabbage 1 each; flour 1.5 cup; eggs 4 each; bacon 6 oz; mayo 3 tbsp; worcestershire 2 tbsp; ketchup 2 tbsp" },
{ title: "Spam Musubi", mins: 25, tools: ["stovetop"], styles: ["comfort", "meal_prep"], about: "Hawaiian-Japanese snack-meal: glazed Spam on rice.", items: "spam 1 each; white_rice 2 cup; soy_sauce 3 tbsp; sugar 2 tbsp; seaweed_snacks 4 each; rice_vinegar 1 tbsp" },
{ title: "Chicken Yakitori Rice Bowls", mins: 25, tools: ["air_fryer"], styles: ["healthy", "quick"], about: "Sweet-soy glazed thigh bites.", items: "chicken_thighs 1.5 lb; soy_sauce 0.25 cup; brown_sugar 2 tbsp; green_onions 2 each; white_rice 1.5 cup" },
{ title: "Rice Cooker Japanese Curry Rice", mins: 10, tools: ["rice_cooker"], styles: ["comfort", "quick"], about: "Curry and rice cooked together.", items: "white_rice 2 cup; chicken_broth 2.5 cup; curry_powder 1 tbsp; chicken_thighs 0.75 lb; carrots 0.5 lb; potatoes 0.5 lb" },
{ title: "Air Fryer Panko Shrimp & Slaw", mins: 20, tools: ["air_fryer"], styles: ["seafood", "quick"], about: "Ebi fry: crunchy shrimp with tangy slaw.", items: "shrimp 1 lb; panko 1 cup; eggs 1 each; coleslaw_mix 8 oz; mayo 3 tbsp; rice_vinegar 1 tbsp; white_rice 1.5 cup" },
]);
export const EXTRA_KOREAN = dishes({ cuisine: "korean" }, [
{ title: "Korean Fried Chicken Drumsticks", mins: 40, tools: ["air_fryer"], styles: ["comfort"], about: "Double-crisped, gochujang-honey glazed.", items: "chicken_drumsticks 3 lb; cornstarch 0.5 cup; gochujang 3 tbsp; honey 3 tbsp; soy_sauce 2 tbsp; minced_garlic 1 tbsp" },
{ title: "Dakgalbi (Spicy Chicken Stir-Fry)", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Gochujang chicken with cabbage and sweet potato.", items: "chicken_thighs 1.5 lb; gochujang 3 tbsp; cabbage 0.5 each; sweet_potatoes 1 each; soy_sauce 2 tbsp; brown_sugar 1 tbsp; white_rice 1.5 cup" },
{ title: "Japchae (Glass Noodle Stir-Fry)", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Sweet soy noodles with beef, spinach and carrots.", items: "rice_noodles 10 oz; beef_stew_meat 0.5 lb; spinach 5 oz; carrots 0.5 lb; mushrooms 4 oz; soy_sauce 0.25 cup; sugar 2 tbsp; sesame_oil 2 tbsp" },
{ title: "Kimchi Jjigae (Kimchi Stew)", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Spicy-sour stew with pork and tofu.", items: "kimchi 2 cup; ground_pork 0.5 lb; tofu 14 oz; gochujang 1 tbsp; chicken_broth 4 cup; green_onions 1 each; white_rice 1.5 cup" },
{ title: "Spicy Gochujang Gnocchi", mins: 20, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Chewy gnocchi stand in for rice cakes in sweet-spicy sauce.", items: "gnocchi 32 oz; gochujang 0.25 cup; sugar 2 tbsp; soy_sauce 1 tbsp; chicken_broth 2 cup; eggs 4 each; green_onions 1 each" },
{ title: "Budae Jjigae (Army Stew)", mins: 25, tools: ["stovetop"], styles: ["comfort"], about: "Spam, hot dogs, kimchi, ramen. Glorious chaos.", items: "spam 1 each; hot_dogs 4 each; kimchi 1 cup; instant_ramen 2 each; tofu 14 oz; gochujang 2 tbsp; chicken_broth 5 cup; mozzarella 0.5 cup" },
{ title: "Korean Beef Tacos", mins: 20, tools: ["stovetop"], styles: ["quick"], about: "Bulgogi beef, kimchi slaw, tortillas.", items: "ground_beef 1 lb; soy_sauce 3 tbsp; brown_sugar 2 tbsp; sesame_oil 1 tbsp; corn_tortillas 12 each; kimchi 1 cup; mayo 2 tbsp; sriracha 1 tbsp" },
{ title: "Kimchi Pancakes", mins: 25, tools: ["stovetop"], styles: ["vegetarian", "quick"], about: "Crispy, tangy, great with soy dipping sauce.", items: "kimchi 1.5 cup; flour 1.5 cup; eggs 1 each; green_onions 1 each; vegetable_oil 3 tbsp; soy_sauce 2 tbsp; rice_vinegar 1 tbsp" },
{ title: "Gochujang Tofu Rice Bowls", mins: 25, tools: ["air_fryer", "microwave"], styles: ["vegetarian", "healthy"], about: "Crispy air-fried tofu in sticky red glaze.", items: "tofu 28 oz; cornstarch 2 tbsp; gochujang 3 tbsp; honey 2 tbsp; soy_sauce 2 tbsp; white_rice 1.5 cup; cucumber 1 each" },
{ title: "Rice Cooker Korean Veggie Rice", id: "rice-cooker-korean-veggie-rice", mins: 10, tools: ["rice_cooker"], styles: ["vegetarian", "healthy"], about: "Rice steamed with veg, served with spicy soy sauce.", items: "white_rice 2 cup; carrots 0.5 lb; mushrooms 4 oz; spinach 3 oz; soy_sauce 3 tbsp; gochujang 1 tbsp; sesame_oil 1 tbsp" },
{ title: "Spicy Pork Bulgogi (Jeyuk)", mins: 25, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Fiery gochujang pork with onions and lettuce wraps.", items: "pork_chops 1.5 lb; gochujang 3 tbsp; soy_sauce 2 tbsp; sugar 1 tbsp; onion 1 each; romaine 1 each; white_rice 1.5 cup" },
]);
export const EXTRA_THAI = dishes({ cuisine: "thai" }, [
{ title: "Pad Thai", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Tangy-sweet rice noodles with chicken, egg, peanuts.", items: "rice_noodles 14 oz; chicken_breast 1 lb; eggs 2 each; fish_sauce 3 tbsp; brown_sugar 3 tbsp; lime 2 each; peanuts 0.33 cup; green_onions 1 each; coleslaw_mix 4 oz" },
{ title: "Shrimp Pad Thai", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort"], about: "The classic, with shrimp.", items: "rice_noodles 14 oz; shrimp 1 lb; eggs 2 each; fish_sauce 3 tbsp; brown_sugar 3 tbsp; lime 2 each; peanuts 0.33 cup; green_onions 1 each" },
{ title: "Drunken Noodles (Pad Kee Mao)", mins: 25, tools: ["stovetop"], styles: ["comfort"], about: "Wide noodles, basil, chili, savory sauce.", items: "rice_noodles 14 oz; chicken_thighs 1 lb; fresh_basil 1 each; oyster_sauce 2 tbsp; soy_sauce 2 tbsp; fish_sauce 1 tbsp; bell_pepper 1 each; jalapeno 2 each" },
{ title: "Pad See Ew", mins: 25, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Charred soy noodles with broccoli and egg.", items: "rice_noodles 14 oz; chicken_breast 1 lb; frozen_broccoli 12 oz; eggs 2 each; soy_sauce 3 tbsp; oyster_sauce 2 tbsp; brown_sugar 1 tbsp" },
{ title: "Shortcut Green Curry", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Tomatillo salsa gives green curry its tang here.", items: "chicken_thighs 1.5 lb; coconut_milk 400 ml; salsa_verde 0.5 cup; red_curry_paste 1 tbsp; fish_sauce 1 tbsp; zucchini 1 each; fresh_basil 1 each; white_rice 1.5 cup" },
{ title: "Panang Curry", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Rich, nutty red curry with peanut butter.", items: "chicken_thighs 1.5 lb; red_curry_paste 3 tbsp; coconut_milk 400 ml; peanut_butter 2 tbsp; fish_sauce 1 tbsp; brown_sugar 1 tbsp; bell_pepper 1 each; white_rice 1.5 cup" },
{ title: "Tom Kha Gai (Coconut Chicken Soup)", mins: 25, tools: ["stovetop"], styles: ["comfort", "healthy"], about: "Creamy, sour-salty coconut soup.", items: "chicken_breast 1 lb; coconut_milk 800 ml; chicken_broth 2 cup; mushrooms 8 oz; ginger_paste 1 tbsp; fish_sauce 2 tbsp; lime 2 each; cilantro 1 each" },
{ title: "Thai Basil Chicken (Gai Pad Krapow)", mins: 20, tools: ["stovetop"], styles: ["quick"], about: "Chicken version of the street-food classic.", items: "ground_chicken 1 lb; fresh_basil 1 each; fish_sauce 1.5 tbsp; oyster_sauce 1 tbsp; jalapeno 2 each; minced_garlic 1 tbsp; eggs 4 each; white_rice 1.5 cup" },
{ title: "Thai Larb Lettuce Cups", mins: 20, tools: ["stovetop"], styles: ["healthy", "quick"], about: "Lime-chili ground chicken salad in lettuce.", items: "ground_chicken 1 lb; lime 3 each; fish_sauce 2 tbsp; red_pepper_flakes 1 tsp; cilantro 1 each; green_onions 1 each; romaine 2 each; white_rice 1 cup" },
{ title: "Sweet Chili Chicken Rice Bowls", mins: 25, tools: ["air_fryer"], styles: ["quick"], about: "Crispy chicken bites tossed in sweet chili.", items: "chicken_breast 1.5 lb; cornstarch 0.33 cup; sweet_chili_sauce 0.5 cup; white_rice 1.5 cup; cucumber 1 each; frozen_broccoli 12 oz" },
{ title: "Thai Peanut Noodle Salad", mins: 20, tools: ["stovetop"], styles: ["vegetarian", "healthy", "meal_prep"], about: "Cold rice noodles, crunchy veg, peanut dressing.", items: "rice_noodles 10 oz; coleslaw_mix 8 oz; carrots 0.5 lb; cucumber 1 each; peanut_butter 0.33 cup; soy_sauce 3 tbsp; lime 2 each; honey 1 tbsp" },
{ title: "Slow Cooker Thai Peanut Chicken", mins: 15, tools: ["slow_cooker"], styles: ["meal_prep", "comfort"], serves: 6, about: "Creamy peanut-coconut chicken over rice.", items: "chicken_thighs 2 lb; peanut_butter 0.5 cup; coconut_milk 400 ml; soy_sauce 0.25 cup; honey 2 tbsp; lime 1 each; red_curry_paste 1 tbsp; white_rice 2 cup" },
{ title: "Thai Pineapple Fried Rice", mins: 20, tools: ["stovetop"], styles: ["quick"], about: "Sweet-savory fried rice with shrimp and curry.", items: "white_rice 2 cup; pineapple 1 each; shrimp 0.5 lb; eggs 2 each; curry_powder 1 tsp; fish_sauce 2 tbsp; peanuts 0.25 cup; green_onions 1 each" },
{ title: "Pork Banh Mi Bowls", id: "banh-mi-bowls", mins: 25, tools: ["stovetop"], styles: ["healthy"], about: "Caramelized pork, quick-pickled carrots, sriracha mayo over rice.", items: "ground_pork 1 lb; fish_sauce 2 tbsp; brown_sugar 2 tbsp; carrots 0.5 lb; cucumber 1 each; rice_vinegar 3 tbsp; mayo 3 tbsp; sriracha 1 tbsp; cilantro 1 each; white_rice 1.5 cup" },
{ title: "Chicken Pho", id: "chicken-pho", mins: 30, tools: ["stovetop"], styles: ["healthy", "comfort"], about: "Spiced broth, rice noodles, herbs and lime.", items: "chicken_broth 8 cup; rotisserie_chicken 2 cup; rice_noodles 14 oz; ginger_paste 1 tbsp; cinnamon 0.5 tsp; fish_sauce 2 tbsp; lime 2 each; cilantro 1 each; fresh_basil 1 each" },
]);

View file

@ -0,0 +1,53 @@
import { dishes } from "../dish";
// Extra breakfasts, every cuisine. Default cuisine: American.
export const EXTRA_BREAKFASTS = dishes({ cuisine: "american", kind: "breakfast" }, [
// --- Eggs & savory ------------------------------------------------------------
{ title: "Classic Bacon, Eggs & Toast", mins: 15, tools: ["stovetop"], styles: ["comfort", "quick"], about: "The diner plate, at home.", items: "eggs 8 each; bacon 8 oz; sandwich_bread 8 each; butter 2 tbsp" },
{ title: "Sausage Egg & Cheese Muffins", mins: 20, tools: ["stovetop"], styles: ["comfort", "meal_prep"], about: "Better than the drive-thru, freezes great.", items: "english_muffins 4 each; breakfast_sausage 0.5 lb; eggs 4 each; cheddar 0.5 cup" },
{ title: "Ham & Cheese Omelets", mins: 15, tools: ["stovetop"], styles: ["quick"], about: "Fluffy folded omelets with diced ham.", items: "eggs 8 each; diced_ham 4 oz; cheddar 0.75 cup; green_onions 1 each; butter 1 tbsp" },
{ title: "Denver Scramble", mins: 15, tools: ["stovetop"], styles: ["quick"], about: "Ham, peppers and onions scrambled together.", items: "eggs 8 each; diced_ham 4 oz; bell_pepper 1 each; onion 0.5 each; cheddar 0.5 cup" },
{ title: "Hash Brown Breakfast Casserole", mins: 55, tools: ["oven"], styles: ["comfort", "meal_prep"], serves: 6, about: "Bake once, breakfast for days.", items: "frozen_hash_browns 20 oz; breakfast_sausage 1 lb; eggs 8 each; milk 1 cup; cheddar 1.5 cup" },
{ title: "Biscuit Breakfast Sandwiches", mins: 25, tools: ["oven", "stovetop"], styles: ["comfort"], about: "Canned biscuits, fried egg, sausage patty.", items: "biscuit_dough 8 each; breakfast_sausage 1 lb; eggs 8 each; cheddar 0.5 cup" },
{ title: "Bagel Egg Sandwiches", mins: 10, tools: ["stovetop"], styles: ["quick"], about: "Toasted bagel, egg, cheese, done.", items: "bagels 4 each; eggs 4 each; cheddar 0.5 cup; butter 1 tbsp" },
{ title: "Bagels & Veggie Cream Cheese", mins: 5, tools: [], styles: ["quick", "vegetarian"], about: "No-cook: cream cheese mixed with green onion and cucumber.", items: "bagels 4 each; cream_cheese 4 oz; green_onions 1 each; cucumber 0.5 each" },
{ title: "Microwave Egg Muffin Mugs", mins: 5, tools: ["microwave"], styles: ["quick", "healthy"], about: "Egg, ham and cheese in a mug; 90 seconds.", items: "eggs 6 each; diced_ham 3 oz; cheddar 0.5 cup; spinach 1 oz" },
{ title: "Microwave Breakfast Quesadilla", mins: 5, tools: ["microwave"], styles: ["quick"], about: "Egg and cheese folded into a tortilla.", items: "flour_tortillas 4 each; eggs 4 each; cheddar 0.5 cup; salsa 0.25 cup" },
{ title: "Air Fryer Breakfast Potatoes & Sausage", mins: 25, tools: ["air_fryer"], styles: ["comfort"], about: "Crispy diced potatoes with sausage links.", items: "potatoes 1.5 lb; breakfast_sausage 0.5 lb; onion 1 each; paprika 1 tsp; vegetable_oil 1 tbsp" },
{ title: "Air Fryer Egg Bites", mins: 15, tools: ["air_fryer"], styles: ["healthy", "meal_prep"], about: "Sous-vide style bites in silicone cups.", items: "eggs 8 each; cheddar 0.5 cup; spinach 1 oz; bacon 3 oz; milk 0.25 cup" },
{ title: "Spinach Mushroom Frittata", mins: 30, tools: ["stovetop", "oven"], styles: ["vegetarian", "healthy", "meal_prep"], about: "Slice into wedges for the week.", items: "eggs 10 each; mushrooms 8 oz; spinach 3 oz; parmesan 0.25 cup; onion 0.5 each" },
{ title: "Sheet-Pan Pancakes", mins: 25, tools: ["oven"], styles: ["comfort", "vegetarian", "meal_prep"], serves: 6, about: "One pan, no flipping, cut into squares.", items: "flour 2 cup; milk 1.5 cup; eggs 2 each; sugar 2 tbsp; baking_powder 1 tbsp; butter 3 tbsp; frozen_berries 1 cup; pancake_syrup 0.5 cup" },
{ title: "Chocolate Chip Pancakes", mins: 20, tools: ["stovetop"], styles: ["comfort", "vegetarian"], about: "Weekend treat mode.", items: "flour 2 cup; milk 1.5 cup; eggs 2 each; sugar 2 tbsp; baking_powder 1 tbsp; chocolate_chips 0.5 cup; pancake_syrup 0.5 cup" },
{ title: "French Toast", mins: 20, tools: ["stovetop"], styles: ["comfort", "vegetarian"], about: "Cinnamon-vanilla custard bread, golden in butter.", items: "sandwich_bread 8 each; eggs 4 each; milk 1 cup; cinnamon 1 tsp; butter 2 tbsp; pancake_syrup 0.5 cup" },
{ title: "Overnight French Toast Bake", mins: 50, tools: ["oven"], styles: ["comfort", "vegetarian", "meal_prep"], serves: 6, about: "Assemble tonight, bake in the morning.", items: "sandwich_bread 12 each; eggs 6 each; milk 2 cup; brown_sugar 0.33 cup; cinnamon 2 tsp; butter 3 tbsp" },
{ title: "Pumpkin Spice Oatmeal", mins: 10, tools: ["stovetop"], styles: ["healthy", "vegetarian"], about: "Fall in a bowl.", items: "oats 2 cup; milk 3 cup; canned_pumpkin 0.5 each; brown_sugar 2 tbsp; cinnamon 1 tsp" },
{ title: "Apple Cinnamon Oatmeal", mins: 10, tools: ["stovetop"], styles: ["healthy", "vegetarian", "quick"], about: "Diced apple simmered right in.", items: "oats 2 cup; milk 3 cup; apples 2 each; cinnamon 1 tsp; brown_sugar 2 tbsp" },
{ title: "Baked Oatmeal Squares", mins: 40, tools: ["oven"], styles: ["meal_prep", "vegetarian"], serves: 6, about: "Cake-like oatmeal you can hold.", items: "oats 3 cup; milk 2 cup; eggs 2 each; bananas 2 each; honey 0.33 cup; baking_powder 1 tsp; cinnamon 1 tsp; frozen_berries 1 cup" },
{ title: "Slow Cooker Apple Pie Oats", mins: 10, tools: ["slow_cooker"], styles: ["meal_prep", "vegetarian", "comfort"], serves: 6, about: "Set at bedtime, wake up to breakfast.", items: "oats 2 cup; milk 4 cup; apples 3 each; brown_sugar 0.25 cup; cinnamon 2 tsp; butter 2 tbsp" },
{ title: "Microwave Chocolate PB Oats", mins: 5, tools: ["microwave"], styles: ["quick", "vegetarian"], about: "Tastes like dessert, fuels like breakfast.", items: "oats 2 cup; milk 3 cup; cocoa_powder 2 tbsp; peanut_butter 3 tbsp; honey 2 tbsp" },
{ title: "Yogurt Granola Parfaits", mins: 5, tools: [], styles: ["quick", "healthy", "vegetarian"], about: "Layer, grab, go.", items: "greek_yogurt 2 cup; granola 1 cup; frozen_berries 1.5 cup; honey 2 tbsp" },
{ title: "PB Banana Toast", mins: 5, tools: [], styles: ["quick", "vegetarian"], about: "Untoasted works; toasted is better.", items: "sandwich_bread 8 each; peanut_butter 0.5 cup; bananas 3 each; honey 1 tbsp" },
{ title: "Cinnamon Apple Crescent Bites", mins: 20, tools: ["oven"], styles: ["comfort", "vegetarian"], about: "Apple slices rolled in crescent dough.", items: "crescent_rolls 8 each; apples 2 each; cinnamon 1 tsp; sugar 2 tbsp; butter 2 tbsp" },
{ title: "Cornbread Breakfast Muffins", mins: 25, tools: ["oven"], styles: ["meal_prep"], about: "Cornbread mix studded with ham and cheese.", items: "cornbread_mix 2 each; eggs 2 each; milk 0.66 cup; diced_ham 4 oz; cheddar 0.5 cup" },
// --- Around the world ------------------------------------------------------
{ title: "Chilaquiles Rojos", cuisine: "mexican", mins: 20, tools: ["stovetop"], styles: ["comfort"], about: "Tortilla chips simmered in red sauce, topped with eggs.", items: "tortilla_chips 8 oz; enchilada_sauce 1 each; eggs 4 each; sour_cream 0.25 cup; cilantro 1 each" },
{ title: "Chorizo Breakfast Tacos", cuisine: "mexican", mins: 15, tools: ["stovetop"], styles: ["quick", "comfort"], about: "Spicy chorizo scrambled with eggs.", items: "chorizo 9 oz; eggs 6 each; corn_tortillas 8 each; salsa 0.5 cup; cilantro 1 each" },
{ title: "Migas", cuisine: "mexican", mins: 15, tools: ["stovetop"], styles: ["quick", "vegetarian"], about: "Tex-Mex eggs with crispy tortilla strips.", items: "eggs 8 each; corn_tortillas 4 each; bell_pepper 1 each; onion 0.5 each; cheddar 0.5 cup; salsa 0.5 cup" },
{ title: "Breakfast Burrito Bowls", cuisine: "mexican", mins: 20, tools: ["stovetop"], styles: ["meal_prep"], about: "Potatoes, eggs, black beans and salsa.", items: "potatoes 1 lb; eggs 6 each; black_beans 1 each; salsa 0.5 cup; cheddar 0.5 cup" },
{ title: "Turkish Eggs (Çılbır)", cuisine: "middle_eastern", mins: 15, tools: ["stovetop"], styles: ["vegetarian", "quick"], about: "Poached eggs on garlicky yogurt with chili butter.", items: "eggs 8 each; greek_yogurt 1.5 cup; minced_garlic 1 tsp; butter 2 tbsp; red_pepper_flakes 1 tsp; pita_bread 4 each" },
{ title: "Ful Medames", cuisine: "middle_eastern", mins: 15, tools: ["stovetop"], styles: ["vegetarian", "healthy"], about: "Warm mashed beans with lemon, cumin and olive oil.", items: "pinto_beans 2 each; lemon 1 each; cumin 1 tsp; minced_garlic 1 tsp; olive_oil 2 tbsp; pita_bread 4 each; roma_tomatoes 2 each" },
{ title: "Masala Omelet", cuisine: "indian", mins: 15, tools: ["stovetop"], styles: ["vegetarian", "quick"], about: "Spiced omelet with onion, tomato and chili.", items: "eggs 8 each; onion 1 each; roma_tomatoes 2 each; jalapeno 1 each; curry_powder 1 tsp; cilantro 1 each" },
{ title: "Savory Upma-Style Couscous", cuisine: "indian", mins: 15, tools: ["stovetop"], styles: ["vegetarian", "quick"], about: "Spiced couscous with peas and onion.", items: "couscous 1.5 cup; frozen_peas 1 cup; onion 1 each; curry_powder 1 tsp; butter 2 tbsp; peanuts 0.25 cup" },
{ title: "Japanese Breakfast Rice Bowls", cuisine: "japanese", mins: 20, tools: ["rice_cooker", "stovetop"], styles: ["healthy"], about: "Rice, flaked salmon, egg and miso soup.", items: "white_rice 1.5 cup; canned_salmon 1 each; eggs 4 each; miso 2 tbsp; green_onions 1 each; seaweed_snacks 2 each" },
{ title: "Spam & Egg Rice Bowls", cuisine: "japanese", mins: 15, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Hawaiian-style seared Spam over rice.", items: "spam 1 each; eggs 4 each; white_rice 1.5 cup; soy_sauce 2 tbsp; brown_sugar 1 tbsp; seaweed_snacks 2 each" },
{ title: "Korean Steamed Egg (Gyeran-jjim)", cuisine: "korean", mins: 10, tools: ["microwave"], styles: ["quick", "healthy", "vegetarian"], about: "Fluffy savory egg custard, microwave version.", items: "eggs 6 each; chicken_broth 1 cup; green_onions 1 each; sesame_oil 1 tsp; white_rice 1 cup" },
{ title: "Thai Jok (Rice Porridge)", cuisine: "thai", mins: 10, tools: ["rice_cooker"], styles: ["comfort", "healthy"], about: "Silky rice porridge with pork meatballs and ginger.", items: "white_rice 1 cup; chicken_broth 6 cup; ground_pork 0.5 lb; ginger_paste 1 tbsp; fish_sauce 1 tbsp; green_onions 1 each" },
{ title: "Caribbean Tuna & Pepper Scramble", cuisine: "caribbean", mins: 15, tools: ["stovetop"], styles: ["quick"], about: "Jamaican-inspired tuna, peppers and onion scramble.", items: "canned_tuna 2 each; eggs 4 each; bell_pepper 1 each; onion 1 each; roma_tomatoes 2 each; jerk_seasoning 1 tsp" },
{ title: "Grits & Eggs", cuisine: "southern", mins: 15, tools: ["stovetop"], styles: ["comfort", "vegetarian", "quick"], about: "Buttery cheese grits with fried eggs.", items: "grits 1 cup; milk 2 cup; butter 2 tbsp; cheddar 0.75 cup; eggs 4 each" },
{ title: "Country Ham Biscuits", cuisine: "southern", mins: 20, tools: ["oven"], styles: ["comfort"], about: "Biscuits split and stuffed with ham and mustard butter.", items: "biscuit_dough 8 each; diced_ham 8 oz; butter 2 tbsp; mustard 1 tbsp" },
{ title: "Italian Ricotta Toast", cuisine: "italian", mins: 5, tools: [], styles: ["quick", "vegetarian"], about: "Creamy ricotta, honey and berries on bread.", items: "sandwich_bread 8 each; ricotta 1 cup; honey 2 tbsp; frozen_berries 1 cup" },
{ title: "Greek Yogurt Honey Bowls", cuisine: "mediterranean", mins: 5, tools: [], styles: ["quick", "healthy", "vegetarian"], about: "Thick yogurt, honey, granola and banana.", items: "greek_yogurt 2 cup; honey 3 tbsp; granola 1 cup; bananas 2 each" },
{ title: "Soy Egg Congee", cuisine: "chinese", mins: 10, tools: ["slow_cooker"], styles: ["comfort", "vegetarian"], about: "Plain silky congee topped with soy-marinated eggs.", items: "white_rice 1 cup; chicken_broth 6 cup; eggs 4 each; soy_sauce 3 tbsp; green_onions 1 each; sesame_oil 1 tsp" },
{ title: "Scallion Pancakes", cuisine: "chinese", mins: 30, tools: ["stovetop"], styles: ["vegetarian", "comfort"], about: "Flaky pan-fried flatbreads with green onion.", items: "flour 2 cup; green_onions 2 each; sesame_oil 1 tbsp; vegetable_oil 3 tbsp; soy_sauce 2 tbsp; rice_vinegar 1 tbsp" },
]);

View file

@ -0,0 +1,59 @@
import { dishes } from "../dish";
// Extra mains: Italian, Mediterranean, Middle Eastern.
export const EXTRA_ITALIAN = dishes({ cuisine: "italian" }, [
{ title: "Classic Lasagna", mins: 75, tools: ["stovetop", "oven"], styles: ["comfort", "meal_prep"], serves: 8, about: "Meat sauce, ricotta, mozzarella, layers.", items: "lasagna_noodles 12 oz; ground_beef 1 lb; marinara 2 each; ricotta 1.5 cup; mozzarella 2 cup; parmesan 0.5 cup; eggs 1 each" },
{ title: "Spinach Ricotta Lasagna Roll-Ups", mins: 50, tools: ["stovetop", "oven"], styles: ["vegetarian", "meal_prep"], about: "Individual rolls, easy to portion.", items: "lasagna_noodles 8 oz; ricotta 1.5 cup; spinach 5 oz; mozzarella 1.5 cup; marinara 1 each; parmesan 0.25 cup" },
{ title: "Chicken Parmesan", mins: 40, tools: ["oven", "stovetop"], styles: ["comfort"], about: "Crispy panko chicken, marinara, melty cheese, spaghetti.", items: "chicken_breast 1.5 lb; panko 1 cup; eggs 1 each; parmesan 0.5 cup; marinara 1 each; mozzarella 1 cup; spaghetti 12 oz" },
{ title: "Air Fryer Chicken Parm", mins: 30, tools: ["air_fryer", "stovetop"], styles: ["comfort", "quick"], about: "All the crunch, none of the frying oil.", items: "chicken_breast 1.5 lb; breadcrumbs 1 cup; eggs 1 each; marinara 1 each; mozzarella 1 cup; penne 12 oz" },
{ title: "Zucchini Parmesan", mins: 50, tools: ["oven"], styles: ["vegetarian", "comfort"], about: "Breaded zucchini rounds baked in sauce.", items: "zucchini 3 each; panko 1 cup; eggs 2 each; marinara 1 each; mozzarella 1.5 cup; parmesan 0.5 cup; spaghetti 12 oz" },
{ title: "Baked Penne with Sausage", mins: 40, tools: ["stovetop", "oven"], styles: ["comfort", "meal_prep"], serves: 6, about: "Smoky sausage, red sauce, bubbly cheese.", items: "penne 1 lb; smoked_sausage 13 oz; marinara 1 each; crushed_tomatoes 1 each; mozzarella 2 cup; italian_seasoning 1 tsp" },
{ title: "Ham & Pea Alfredo", mins: 20, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Jarred Alfredo made weeknight-fancy.", items: "spaghetti 1 lb; alfredo_sauce 1 each; diced_ham 8 oz; frozen_peas 1 cup; parmesan 0.25 cup" },
{ title: "Cheese Tortellini Soup", mins: 25, tools: ["stovetop"], styles: ["comfort", "vegetarian", "quick"], about: "Tomato-cream broth with pillowy tortellini.", items: "tortellini 20 oz; crushed_tomatoes 1 each; chicken_broth 4 cup; heavy_cream 0.5 cup; spinach 3 oz; italian_seasoning 1 tsp" },
{ title: "Sausage Tortellini Skillet", mins: 20, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Tortellini, sausage and spinach in creamy tomato.", items: "tortellini 20 oz; smoked_sausage 6.5 oz; marinara 1 each; heavy_cream 0.5 cup; spinach 3 oz" },
{ title: "Brown Butter Gnocchi with Spinach", mins: 20, tools: ["stovetop"], styles: ["vegetarian", "quick"], about: "Crispy pan-seared gnocchi, nutty butter, parmesan.", items: "gnocchi 32 oz; butter 4 tbsp; spinach 5 oz; parmesan 0.5 cup; minced_garlic 1 tsp" },
{ title: "Sheet-Pan Gnocchi & Sausage", mins: 30, tools: ["oven"], styles: ["comfort", "quick"], about: "Roasted gnocchi with peppers and sausage.", items: "gnocchi 32 oz; smoked_sausage 13 oz; bell_pepper 2 each; onion 1 each; olive_oil 2 tbsp; italian_seasoning 1 tsp" },
{ title: "Italian Wedding Soup", mins: 35, tools: ["stovetop"], styles: ["comfort", "healthy"], serves: 6, about: "Mini meatballs, greens and tiny pasta.", items: "frozen_meatballs 16 oz; chicken_broth 8 cup; elbow_macaroni 6 oz; spinach 5 oz; carrots 0.5 lb; onion 1 each; parmesan 0.25 cup" },
{ title: "Minestrone", mins: 40, tools: ["stovetop"], styles: ["vegetarian", "healthy", "meal_prep"], serves: 6, about: "Vegetable-bean soup that feeds an army.", items: "white_beans 2 each; diced_tomatoes 1 each; zucchini 1 each; carrots 0.5 lb; celery 1 each; elbow_macaroni 6 oz; chicken_broth 6 cup; italian_seasoning 1 tsp" },
{ title: "Spaghetti Aglio e Olio", mins: 15, tools: ["stovetop"], styles: ["vegetarian", "quick"], about: "Garlic, olive oil, chili. Pantry magic.", items: "spaghetti 1 lb; olive_oil 0.33 cup; minced_garlic 2 tbsp; red_pepper_flakes 1 tsp; parmesan 0.5 cup" },
{ title: "Pasta Carbonara", mins: 20, tools: ["stovetop"], styles: ["comfort", "quick"], about: "Eggs, cheese and bacon; silky, no cream.", items: "spaghetti 1 lb; bacon 8 oz; eggs 4 each; parmesan 1 cup; black_pepper 1 tsp" },
{ title: "Creamy Tuscan Chicken", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Sun-dried-style tomato cream with spinach.", items: "chicken_breast 1.5 lb; heavy_cream 1 cup; diced_tomatoes 1 each; spinach 3 oz; parmesan 0.5 cup; minced_garlic 1 tbsp; penne 12 oz" },
{ title: "Chicken Cacciatore", mins: 50, tools: ["stovetop"], styles: ["comfort", "meal_prep"], about: "Hunter-style braised thighs with peppers and mushrooms.", items: "chicken_thighs 2 lb; crushed_tomatoes 1 each; bell_pepper 2 each; mushrooms 8 oz; onion 1 each; italian_seasoning 2 tsp; spaghetti 12 oz" },
{ title: "Slow Cooker Italian Meatballs", mins: 10, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], about: "Frozen meatballs simmered all day in sauce.", items: "frozen_meatballs 26 oz; marinara 2 each; spaghetti 1 lb; parmesan 0.25 cup" },
{ title: "Pizza Pasta Bake", mins: 35, tools: ["stovetop", "oven"], styles: ["comfort"], about: "Pepperoni pizza, but make it pasta.", items: "penne 1 lb; marinara 1 each; pepperoni 6 oz; mozzarella 2 cup; italian_seasoning 1 tsp" },
{ title: "Calzones", mins: 35, tools: ["oven"], styles: ["comfort"], about: "Folded pizza stuffed with ricotta and pepperoni.", items: "pizza_dough 2 each; ricotta 1 cup; mozzarella 1.5 cup; pepperoni 4 oz; marinara 1 each" },
{ title: "Margherita Flatbreads", mins: 15, tools: ["oven"], styles: ["vegetarian", "quick"], about: "Naan pizzas with tomato, mozzarella, basil.", items: "naan 4 each; tomato_sauce 1 each; mozzarella 1.5 cup; fresh_basil 1 each; roma_tomatoes 2 each" },
{ title: "Pasta Primavera", mins: 25, tools: ["stovetop"], styles: ["vegetarian", "healthy", "quick"], about: "Spring veg in a light parmesan sauce.", items: "penne 1 lb; zucchini 1 each; bell_pepper 1 each; frozen_peas 1 cup; parmesan 0.5 cup; olive_oil 2 tbsp; lemon 1 each" },
{ title: "Microwave Mug Lasagna", mins: 8, tools: ["microwave"], styles: ["quick", "comfort"], about: "Broken noodles, ricotta, sauce, 6 minutes.", items: "lasagna_noodles 6 oz; ricotta 0.75 cup; marinara 1 each; mozzarella 1 cup" },
{ title: "Rice Cooker Mushroom Risotto", mins: 10, tools: ["rice_cooker"], styles: ["vegetarian", "comfort"], about: "No stirring required. Seriously.", items: "white_rice 1.5 cup; chicken_broth 4 cup; mushrooms 8 oz; parmesan 0.75 cup; butter 2 tbsp; onion 0.5 each" },
{ title: "Blender Roasted Red Pepper Pasta", mins: 25, tools: ["stovetop", "blender"], styles: ["vegetarian", "quick"], about: "Blitzed pepper-cream sauce over penne.", items: "penne 1 lb; bell_pepper 3 each; cream_cheese 4 oz; parmesan 0.5 cup; minced_garlic 1 tsp; olive_oil 1 tbsp" },
]);
export const EXTRA_MEDITERRANEAN = dishes({ cuisine: "mediterranean" }, [
{ title: "Greek Chicken Souvlaki Bowls", mins: 30, tools: ["stovetop"], styles: ["healthy", "meal_prep"], about: "Lemon-oregano chicken, rice, tzatziki.", items: "chicken_breast 1.5 lb; lemon 2 each; italian_seasoning 2 tsp; olive_oil 3 tbsp; greek_yogurt 1 cup; cucumber 1 each; white_rice 1.5 cup" },
{ title: "Greek Lemon Chicken Soup (Avgolemono)", mins: 35, tools: ["stovetop"], styles: ["comfort", "healthy"], about: "Silky egg-lemon broth with rice and chicken.", items: "rotisserie_chicken 2 cup; chicken_broth 8 cup; white_rice 0.75 cup; eggs 3 each; lemon 2 each" },
{ title: "Spanakopita Hand Pies", mins: 35, tools: ["oven"], styles: ["vegetarian"], about: "Spinach-feta filling in flaky crescent dough.", items: "crescent_rolls 16 each; spinach 10 oz; feta 1 cup; ricotta 0.5 cup; onion 0.5 each; eggs 1 each" },
{ title: "Greek Couscous Salad", mins: 15, tools: ["stovetop"], styles: ["vegetarian", "healthy", "quick", "meal_prep"], about: "Couscous, cucumber, tomato, feta, lemon.", items: "couscous 1.5 cup; cucumber 1 each; roma_tomatoes 3 each; feta 0.5 cup; lemon 1 each; olive_oil 3 tbsp; chickpeas 1 each" },
{ title: "Sheet-Pan Greek Chicken & Potatoes", mins: 45, tools: ["oven"], styles: ["healthy", "meal_prep"], about: "Lemony roasted thighs and potatoes.", items: "chicken_thighs 2 lb; potatoes 1.5 lb; lemon 2 each; italian_seasoning 2 tsp; minced_garlic 1 tbsp; olive_oil 3 tbsp; feta 0.25 cup" },
{ title: "Mediterranean Quinoa Bowls", mins: 25, tools: ["stovetop"], styles: ["vegetarian", "healthy", "meal_prep"], about: "Quinoa, chickpeas, veg, lemon-yogurt drizzle.", items: "quinoa 1.5 cup; chickpeas 1 each; cucumber 1 each; roma_tomatoes 2 each; feta 0.5 cup; lemon 1 each; greek_yogurt 0.5 cup" },
{ title: "Spanish Chickpea & Spinach Stew", mins: 25, tools: ["stovetop"], styles: ["vegetarian", "healthy"], about: "Espinacas con garbanzos: smoky, garlicky.", items: "chickpeas 2 each; spinach 5 oz; diced_tomatoes 1 each; paprika 2 tsp; cumin 1 tsp; minced_garlic 1 tbsp; olive_oil 2 tbsp; sandwich_bread 4 each" },
{ title: "Spanish Chorizo & Potato Skillet", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Patatas-style potatoes with spicy chorizo.", items: "chorizo 9 oz; potatoes 1.5 lb; onion 1 each; bell_pepper 1 each; paprika 1 tsp; eggs 4 each" },
{ title: "Tuscan White Bean Soup", mins: 30, tools: ["stovetop"], styles: ["vegetarian", "healthy", "meal_prep"], serves: 6, about: "Creamy beans, garlic, greens.", items: "white_beans 3 each; chicken_broth 4 cup; spinach 5 oz; carrots 0.5 lb; celery 1 each; minced_garlic 1 tbsp; parmesan 0.25 cup" },
{ title: "Air Fryer Mediterranean Salmon", mins: 20, tools: ["air_fryer", "stovetop"], styles: ["seafood", "healthy", "quick"], about: "Lemon-herb salmon with couscous.", items: "salmon 1 lb; lemon 1 each; italian_seasoning 1 tsp; olive_oil 1 tbsp; couscous 1.5 cup; cucumber 1 each" },
{ title: "Slow Cooker Greek Chicken Pitas", mins: 10, tools: ["slow_cooker"], styles: ["meal_prep", "healthy"], about: "Shredded lemon-garlic chicken for pitas all week.", items: "chicken_breast 2 lb; lemon 2 each; minced_garlic 1 tbsp; italian_seasoning 2 tsp; pita_bread 6 each; greek_yogurt 1 cup; cucumber 1 each" },
{ title: "Blender Hummus Plates", mins: 10, tools: ["blender"], styles: ["vegetarian", "quick", "healthy"], about: "Homemade hummus with pita, veg and feta.", items: "chickpeas 2 each; lemon 1 each; olive_oil 0.25 cup; minced_garlic 1 tsp; cumin 1 tsp; pita_bread 6 each; cucumber 1 each; carrots 0.5 lb" },
]);
export const EXTRA_MIDDLE_EASTERN = dishes({ cuisine: "middle_eastern" }, [
{ title: "Kofta Kebab Plates", mins: 30, tools: ["stovetop"], styles: ["comfort"], about: "Spiced beef kofta with rice and yogurt sauce.", items: "ground_beef 1 lb; onion 1 each; cumin 2 tsp; cinnamon 0.5 tsp; greek_yogurt 1 cup; white_rice 1.5 cup; cucumber 1 each" },
{ title: "Chicken Kofta Pitas", mins: 25, tools: ["air_fryer"], styles: ["healthy"], about: "Herby ground-chicken skewers, air-fried.", items: "ground_chicken 1 lb; onion 0.5 each; cilantro 1 each; cumin 2 tsp; pita_bread 4 each; greek_yogurt 0.5 cup; roma_tomatoes 2 each" },
{ title: "Lebanese Lentil Soup", mins: 35, tools: ["stovetop", "blender"], styles: ["vegetarian", "healthy", "meal_prep"], serves: 6, about: "Blended golden lentil soup with lemon.", items: "red_lentils 2 cup; onion 1 each; carrots 0.5 lb; cumin 2 tsp; chicken_broth 6 cup; lemon 2 each; olive_oil 2 tbsp" },
{ title: "Chicken Shawarma Rice Bowls", mins: 30, tools: ["stovetop"], styles: ["meal_prep"], about: "Spiced thighs over rice with garlic sauce.", items: "chicken_thighs 1.5 lb; cumin 2 tsp; paprika 2 tsp; cinnamon 0.25 tsp; white_rice 1.5 cup; greek_yogurt 0.75 cup; minced_garlic 1 tbsp; cabbage 0.5 each" },
{ title: "Fattoush Chicken Salad", mins: 20, tools: ["oven"], styles: ["healthy", "quick"], about: "Crispy toasted pita chips, crunchy veg, lemon.", items: "pita_bread 3 each; rotisserie_chicken 2 cup; romaine 2 each; cucumber 1 each; roma_tomatoes 3 each; lemon 2 each; olive_oil 3 tbsp" },
{ title: "Shakshuka with Chickpeas & Feta", mins: 25, tools: ["stovetop"], styles: ["vegetarian", "comfort"], about: "Dinner-sized shakshuka with beans.", items: "eggs 8 each; crushed_tomatoes 1 each; chickpeas 1 each; bell_pepper 1 each; cumin 1 tsp; paprika 1 tsp; feta 0.5 cup; pita_bread 4 each" },
{ title: "Moroccan Chickpea Tagine", mins: 35, tools: ["stovetop"], styles: ["vegetarian", "healthy"], about: "Sweet-spiced stew with sweet potatoes over couscous.", items: "chickpeas 2 each; sweet_potatoes 2 each; diced_tomatoes 1 each; onion 1 each; cumin 2 tsp; cinnamon 1 tsp; couscous 1.5 cup" },
{ title: "Slow Cooker Moroccan Chicken", mins: 15, tools: ["slow_cooker"], styles: ["comfort", "meal_prep"], about: "Cumin, cinnamon, carrots and chickpeas.", items: "chicken_thighs 2 lb; chickpeas 1 each; carrots 1 lb; onion 1 each; cumin 2 tsp; cinnamon 1 tsp; chicken_broth 1 cup; couscous 1.5 cup" },
{ title: "Herbed Rice & Beans", mins: 10, tools: ["rice_cooker"], styles: ["vegetarian"], about: "Rice cooker rice with herbs, beans and crispy bottom.", items: "white_rice 2 cup; white_beans 1 each; cilantro 1 each; green_onions 1 each; butter 2 tbsp; cumin 1 tsp" },
{ title: "Microwave Couscous Tabbouleh Bowls", mins: 10, tools: ["microwave"], styles: ["vegetarian", "quick", "healthy"], about: "Herby lemon couscous with chickpeas.", items: "couscous 1.5 cup; cucumber 1 each; roma_tomatoes 3 each; cilantro 1 each; lemon 2 each; olive_oil 3 tbsp; chickpeas 1 each" },
]);

View file

@ -0,0 +1,24 @@
import { dishes } from "../dish";
// Extra seafood mains, all tagged "seafood". Cuisine set per dish.
export const EXTRA_SEAFOOD = dishes({ cuisine: "american" }, [
{ title: "Salmon Patties", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort", "quick"], about: "Canned-salmon cakes, crispy outside.", items: "canned_salmon 1 each; breadcrumbs 0.5 cup; eggs 1 each; mayo 2 tbsp; lemon 1 each; frozen_peas 1.5 cup; potatoes 1 lb" },
{ title: "Tuna Salad Sandwiches", mins: 10, tools: [], styles: ["seafood", "quick"], about: "No-cook lunch-counter classic.", items: "canned_tuna 3 each; mayo 0.33 cup; celery 1 each; sandwich_bread 8 each; romaine 1 each" },
{ title: "Fish Stick Tacos", mins: 20, tools: ["air_fryer"], styles: ["seafood", "quick"], about: "Kid-approved crunchy fish tacos.", items: "fish_sticks 16 oz; corn_tortillas 12 each; coleslaw_mix 6 oz; sour_cream 0.33 cup; lime 1 each; salsa 0.5 cup" },
{ title: "Air Fryer Fish & Chips", mins: 30, tools: ["air_fryer"], styles: ["seafood", "comfort"], about: "Panko pollock and crinkle fries with tartar.", items: "pollock 1.5 lb; panko 1 cup; eggs 1 each; frozen_fries 24 oz; mayo 0.25 cup; lemon 1 each" },
{ title: "New England Clam Chowder", mins: 35, tools: ["stovetop"], styles: ["seafood", "comfort"], serves: 6, about: "Creamy, bacon-y, potato-packed.", items: "canned_clams 3 each; potatoes 1.5 lb; bacon 4 oz; onion 1 each; celery 1 each; milk 2 cup; heavy_cream 0.5 cup; flour 3 tbsp" },
{ title: "Garlic Butter Shrimp & Rice", mins: 20, tools: ["stovetop"], styles: ["seafood", "quick"], about: "Shrimp scampi energy, over rice.", items: "shrimp 1 lb; butter 4 tbsp; minced_garlic 1 tbsp; lemon 1 each; red_pepper_flakes 0.5 tsp; white_rice 1.5 cup; frozen_broccoli 12 oz" },
{ title: "Shrimp Scampi Pasta", cuisine: "italian", mins: 20, tools: ["stovetop"], styles: ["seafood", "quick"], about: "Garlic, butter, lemon, linguine.", items: "spaghetti 1 lb; shrimp 1 lb; butter 4 tbsp; minced_garlic 1 tbsp; lemon 1 each; parmesan 0.25 cup; red_pepper_flakes 0.5 tsp" },
{ title: "Tuna Pasta Puttanesca", cuisine: "italian", mins: 20, tools: ["stovetop"], styles: ["seafood", "quick"], about: "Bold tomato sauce with tuna and chili.", items: "spaghetti 1 lb; canned_tuna 2 each; crushed_tomatoes 1 each; minced_garlic 1 tbsp; red_pepper_flakes 1 tsp; olive_oil 2 tbsp" },
{ title: "Baked Pollock Provençal", cuisine: "mediterranean", id: "baked-pollock-provencal", mins: 30, tools: ["oven"], styles: ["seafood", "healthy"], about: "White fish baked in tomatoes and garlic.", items: "pollock 1.5 lb; diced_tomatoes 2 each; minced_garlic 1 tbsp; italian_seasoning 1 tsp; olive_oil 2 tbsp; couscous 1.5 cup" },
{ title: "Teriyaki Salmon Sheet Pan", cuisine: "japanese", mins: 25, tools: ["oven"], styles: ["seafood", "healthy", "meal_prep"], about: "Glazed salmon with green beans.", items: "salmon 1.5 lb; teriyaki_sauce 0.33 cup; frozen_green_beans 12 oz; white_rice 1.5 cup" },
{ title: "Korean Spicy Tuna Mayo Rice Bowls", cuisine: "korean", mins: 10, tools: ["microwave"], styles: ["seafood", "quick"], about: "Chamchi-mayo: tuna, mayo, gochujang, seaweed.", items: "canned_tuna 3 each; mayo 3 tbsp; gochujang 1 tbsp; white_rice 1.5 cup; seaweed_snacks 3 each; eggs 2 each" },
{ title: "Thai Fish Cakes", cuisine: "thai", mins: 30, tools: ["stovetop"], styles: ["seafood"], about: "Red curry pollock cakes with sweet chili dip.", items: "pollock 1 lb; red_curry_paste 2 tbsp; eggs 1 each; cornstarch 2 tbsp; frozen_green_beans 4 oz; sweet_chili_sauce 0.33 cup; cucumber 1 each" },
{ title: "Chinese Steamed Fish with Ginger & Scallion", cuisine: "chinese", mins: 20, tools: ["stovetop"], styles: ["seafood", "healthy"], about: "Delicate tilapia with sizzling scallion oil.", items: "tilapia 1.5 lb; ginger_paste 1 tbsp; green_onions 2 each; soy_sauce 3 tbsp; vegetable_oil 2 tbsp; white_rice 1.5 cup" },
{ title: "Blackened Tilapia Tacos", cuisine: "mexican", mins: 20, tools: ["stovetop"], styles: ["seafood", "quick", "healthy"], about: "Spicy seared fish with avocado crema.", items: "tilapia 1 lb; cajun_seasoning 1 tbsp; corn_tortillas 12 each; avocado 1 each; sour_cream 0.33 cup; lime 1 each; cabbage 0.25 each" },
{ title: "Shrimp Ceviche Tostadas", cuisine: "mexican", mins: 25, tools: ["stovetop"], styles: ["seafood", "healthy"], about: "Lime-cured (and briefly poached) shrimp, crunchy tostadas.", items: "shrimp 1 lb; lime 4 each; roma_tomatoes 3 each; onion 0.5 each; cilantro 1 each; jalapeno 1 each; avocado 1 each; corn_tortillas 12 each" },
{ title: "Rice Cooker Shrimp Jambalaya", cuisine: "southern", mins: 10, tools: ["rice_cooker"], styles: ["seafood", "comfort", "quick"], about: "One-button Cajun shrimp and rice.", items: "white_rice 2 cup; shrimp 0.75 lb; smoked_sausage 6.5 oz; diced_tomatoes 1 each; chicken_broth 2 cup; cajun_seasoning 2 tsp" },
{ title: "Crab Rangoon Dip Pasta", cuisine: "chinese", mins: 25, tools: ["stovetop"], styles: ["seafood", "comfort"], about: "Creamy imitation-crab sauce with sweet chili.", items: "penne 1 lb; imitation_crab 8 oz; cream_cheese 8 oz; milk 1 cup; green_onions 1 each; sweet_chili_sauce 3 tbsp" },
{ title: "Caribbean Coconut Shrimp Curry", cuisine: "caribbean", mins: 25, tools: ["stovetop"], styles: ["seafood"], about: "Island curry with shrimp and sweet potato.", items: "shrimp 1 lb; coconut_milk 400 ml; curry_powder 1 tbsp; sweet_potatoes 1 each; bell_pepper 1 each; jerk_seasoning 1 tsp; white_rice 1.5 cup" },
]);

112
app/lib/recipes/index.ts Normal file
View file

@ -0,0 +1,112 @@
import type { Recipe, RecipeData } from "@/app/types";
import { CATALOG_BY_ID } from "../catalog";
import { toBaseUnit } from "../units";
import { BREAKFASTS } from "./breakfasts";
import { CAYDENS_CORNER_DESSERTS, CAYDENS_CORNER_MAINS, CAYDENS_CORNER_SIDES } from "./caydens-corner";
import { EXTRA_AMERICAN, EXTRA_SOUTHERN } from "./extra/americas-north";
import { EXTRA_CARIBBEAN, EXTRA_MEXICAN } from "./extra/americas-south";
import { EXTRA_CHINESE, EXTRA_INDIAN, EXTRA_JAPANESE, EXTRA_KOREAN, EXTRA_THAI } from "./extra/asia";
import { EXTRA_BREAKFASTS } from "./extra/breakfasts";
import { EXTRA_ITALIAN, EXTRA_MEDITERRANEAN, EXTRA_MIDDLE_EASTERN } from "./extra/mediterranean";
import { EXTRA_SEAFOOD } from "./extra/seafood";
import { MAINS_AMERICAS } from "./mains-americas";
import { MAINS_ASIA } from "./mains-asia";
import { MAINS_MEDITERRANEAN } from "./mains-mediterranean";
import { SEAFOOD } from "./seafood";
import { CAYDENS_CORNER_STEPS } from "./steps/caydens-corner";
import { CORE_STEPS } from "./steps/core";
import { EXTRA_AMERICAS_STEPS } from "./steps/extra-americas";
import { EXTRA_ASIA_STEPS } from "./steps/extra-asia";
import { EXTRA_BREAKFAST_STEPS } from "./steps/extra-breakfasts";
import { EXTRA_MEDITERRANEAN_STEPS } from "./steps/extra-mediterranean";
import { EXTRA_SEAFOOD_STEPS } from "./steps/extra-seafood";
// ---------------------------------------------------------------------------
// Recipe database.
//
// ./*.ts the original recipes, written as full objects
// ./extra/*.ts the big library, written in the compact `dishes()` format
// ./caydens-corner.ts family recipes
// ./steps/*.ts cooking instructions for all of the above, keyed by recipe id
//
// Ingredients deliberately overlap (rice, chicken, onion, garlic, soy sauce,
// canned tomatoes, tortillas…) so the planner has real overlap to exploit.
// Amounts are for `servings` people and get scaled to household size.
// ---------------------------------------------------------------------------
const CORE: RecipeData[] = [...BREAKFASTS, ...MAINS_AMERICAS, ...MAINS_MEDITERRANEAN, ...MAINS_ASIA, ...SEAFOOD];
const EXTRA: RecipeData[] = [
...EXTRA_BREAKFASTS,
...EXTRA_AMERICAN,
...EXTRA_SOUTHERN,
...EXTRA_MEXICAN,
...EXTRA_CARIBBEAN,
...EXTRA_ITALIAN,
...EXTRA_MEDITERRANEAN,
...EXTRA_MIDDLE_EASTERN,
...EXTRA_INDIAN,
...EXTRA_CHINESE,
...EXTRA_JAPANESE,
...EXTRA_KOREAN,
...EXTRA_THAI,
...EXTRA_SEAFOOD,
];
const STEPS: Record<string, string[]> = {
...CORE_STEPS,
...EXTRA_BREAKFAST_STEPS,
...EXTRA_AMERICAS_STEPS,
...EXTRA_MEDITERRANEAN_STEPS,
...EXTRA_ASIA_STEPS,
...EXTRA_SEAFOOD_STEPS,
...CAYDENS_CORNER_STEPS,
};
/** Cayden's Corner: family recipes. */
const FAMILY: RecipeData[] = [...CAYDENS_CORNER_SIDES, ...CAYDENS_CORNER_MAINS, ...CAYDENS_CORNER_DESSERTS];
const ALL: RecipeData[] = [...CORE, ...EXTRA, ...FAMILY];
// --- Load-time checks ---------------------------------------------------------
// A typo in recipe data should crash the app on startup (and fail `next build`),
// not quietly drop an ingredient from someone's grocery bill.
// 1. Every recipe must have instructions, and every instruction set must belong to a recipe.
const missingSteps = ALL.filter((r) => !STEPS[r.id]?.length).map((r) => r.id);
const recipeIds = new Set(ALL.map((r) => r.id));
const orphanSteps = Object.keys(STEPS).filter((id) => !recipeIds.has(id));
if (missingSteps.length || orphanSteps.length) {
throw new Error(`Recipe steps out of sync. Missing: [${missingSteps.join(", ")}] Orphaned: [${orphanSteps.join(", ")}]`);
}
// 2. Every ingredient must be a real store item, in a unit that converts to how it's sold.
const badIngredients: string[] = [];
for (const recipe of ALL) {
for (const { itemId, amount, unit } of recipe.ingredients) {
const item = CATALOG_BY_ID.get(itemId);
if (!item) {
badIngredients.push(`${recipe.id}: unknown item "${itemId}"`);
continue;
}
try {
toBaseUnit(amount, unit, item);
} catch (error) {
badIngredients.push(`${recipe.id}: ${(error as Error).message}`);
}
}
}
if (badIngredients.length) {
throw new Error(`Bad recipe ingredients:\n ${badIngredients.join("\n ")}`);
}
export const RECIPES: Recipe[] = ALL.map((r) => ({ ...r, steps: STEPS[r.id] }));
export const RECIPES_BY_ID: ReadonlyMap<string, Recipe> = new Map(RECIPES.map((r) => [r.id, r]));
// 3. Duplicate ids would silently shadow each other in RECIPES_BY_ID; fail loudly instead.
if (RECIPES_BY_ID.size !== RECIPES.length) {
const seen = new Set<string>();
const dupes = RECIPES.filter((r) => (seen.has(r.id) ? true : (seen.add(r.id), false))).map((r) => r.id);
throw new Error(`Duplicate recipe ids: ${dupes.join(", ")}`);
}

9
app/lib/recipes/ing.ts Normal file
View file

@ -0,0 +1,9 @@
import type { RecipeIngredient, RecipeUnit } from "@/app/types";
/** Tiny helper so ingredient lists read like a recipe card: ing("rice", 1.5, "cup"). */
export const ing = (itemId: string, amount: number, unit: RecipeUnit, note?: string): RecipeIngredient => ({
itemId,
amount,
unit,
note,
});

View file

@ -0,0 +1,303 @@
import type { RecipeData } from "@/app/types";
import { ing } from "./ing";
// Mains: American, Southern, Mexican, Caribbean
export const MAINS_AMERICAS: RecipeData[] = [
{
id: "pulled-pork",
title: "Slow Cooker BBQ Pulled Pork",
description: "Set it in the morning, sandwiches by dinner.",
cuisine: "american",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker"],
servings: 6,
ingredients: [ing("pork_shoulder", 3, "lb"), ing("bbq_sauce", 1, "cup"), ing("brown_sugar", 2, "tbsp"), ing("onion", 1, "each"), ing("sandwich_bread", 12, "each"), ing("coleslaw_mix", 8, "oz")],
},
{
id: "air-fryer-chicken-potatoes",
title: "Air Fryer Paprika Chicken & Potatoes",
description: "Crispy-skinned thighs with smashed potatoes.",
cuisine: "american",
styles: ["healthy", "quick"],
kind: "main",
prepTime: 30,
requiredAppliances: ["air_fryer"],
servings: 4,
ingredients: [ing("chicken_thighs", 2, "lb"), ing("potatoes", 1.5, "lb"), ing("paprika", 2, "tsp"), ing("minced_garlic", 1, "tbsp"), ing("olive_oil", 2, "tbsp"), ing("salt", 1, "tsp"), ing("black_pepper", 0.5, "tsp")],
},
{
id: "slow-cooker-chili",
title: "Slow Cooker Beef & Bean Chili",
description: "Big pot, freezes great.",
cuisine: "american",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker"],
servings: 6,
ingredients: [ing("ground_beef", 1, "lb"), ing("kidney_beans", 2, "each"), ing("crushed_tomatoes", 1, "each"), ing("onion", 1, "each"), ing("chili_powder", 2, "tbsp"), ing("cumin", 1, "tsp"), ing("cheddar", 0.5, "cup"), ing("sour_cream", 0.5, "cup")],
},
{
id: "mac-and-cheese",
title: "Stovetop Mac & Cheese with Broccoli",
description: "Real cheese sauce in 20 minutes.",
cuisine: "american",
styles: ["comfort", "vegetarian", "quick"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("elbow_macaroni", 1, "lb"), ing("cheddar", 2, "cup"), ing("milk", 2, "cup"), ing("butter", 3, "tbsp"), ing("flour", 3, "tbsp"), ing("frozen_broccoli", 12, "oz")],
},
{
id: "sausage-potato-skillet",
title: "Smoked Sausage & Potato Skillet",
description: "One pan, crispy potatoes, smoky sausage.",
cuisine: "american",
styles: ["comfort", "quick"],
kind: "main",
prepTime: 30,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("smoked_sausage", 13, "oz"), ing("potatoes", 1.5, "lb"), ing("bell_pepper", 1, "each"), ing("onion", 1, "each"), ing("paprika", 1, "tsp"), ing("vegetable_oil", 2, "tbsp")],
},
{
id: "loaded-baked-potatoes",
title: "Microwave Loaded Baked Potatoes",
description: "Dorm-friendly comfort food.",
cuisine: "american",
styles: ["comfort", "quick"],
kind: "main",
prepTime: 15,
requiredAppliances: ["microwave"],
servings: 4,
ingredients: [ing("potatoes", 2, "lb"), ing("cheddar", 1, "cup"), ing("sour_cream", 0.5, "cup"), ing("bacon", 4, "oz"), ing("green_onions", 1, "each"), ing("frozen_broccoli", 12, "oz")],
},
{
id: "sheet-pan-sausage",
title: "Sheet-Pan Sausage & Veggies",
description: "Chop, toss, roast, done.",
cuisine: "american",
styles: ["healthy", "meal_prep"],
kind: "main",
prepTime: 35,
requiredAppliances: ["oven"],
servings: 4,
ingredients: [ing("smoked_sausage", 13, "oz"), ing("potatoes", 1, "lb"), ing("frozen_broccoli", 12, "oz"), ing("bell_pepper", 2, "each"), ing("olive_oil", 2, "tbsp"), ing("italian_seasoning", 2, "tsp")],
},
{
id: "beef-tacos",
title: "Weeknight Beef Tacos",
description: "Seasoned beef, crunchy slaw, salsa.",
cuisine: "mexican",
styles: ["quick", "comfort"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("ground_beef", 1, "lb"), ing("taco_seasoning", 1, "each"), ing("flour_tortillas", 8, "each"), ing("cheddar", 1, "cup"), ing("salsa", 0.5, "cup"), ing("coleslaw_mix", 4, "oz")],
},
{
id: "chicken-tortilla-soup",
title: "Slow Cooker Chicken Tortilla Soup",
description: "Dump-and-go soup with crispy tortilla strips.",
cuisine: "mexican",
styles: ["comfort", "meal_prep", "healthy"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker"],
servings: 6,
ingredients: [ing("chicken_breast", 1.5, "lb"), ing("black_beans", 1, "each"), ing("diced_tomatoes", 2, "each"), ing("chicken_broth", 4, "cup"), ing("onion", 1, "each"), ing("taco_seasoning", 1, "each"), ing("flour_tortillas", 4, "each"), ing("cheddar", 0.5, "cup"), ing("lime", 1, "each")],
},
{
id: "black-bean-quesadillas",
title: "Black Bean & Pepper Quesadillas",
description: "Crispy, cheesy, meatless.",
cuisine: "mexican",
styles: ["quick", "vegetarian"],
kind: "main",
prepTime: 15,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("flour_tortillas", 8, "each"), ing("black_beans", 1, "each"), ing("cheddar", 1.5, "cup"), ing("salsa", 0.5, "cup"), ing("bell_pepper", 1, "each"), ing("cumin", 1, "tsp")],
},
{
id: "chicken-burrito-bowls",
title: "Chicken Burrito Bowls",
description: "Rice, beans, salsa chicken: the meal-prep staple.",
cuisine: "mexican",
styles: ["meal_prep", "healthy"],
kind: "main",
prepTime: 30,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chicken_breast", 1.5, "lb"), ing("white_rice", 1.5, "cup"), ing("black_beans", 1, "each"), ing("salsa", 1, "cup"), ing("taco_seasoning", 1, "each"), ing("lime", 1, "each"), ing("cheddar", 0.5, "cup")],
},
// ===== Southern & Cajun ====================================================
{
id: "red-beans-and-rice",
title: "Slow Cooker Red Beans & Rice",
description: "New Orleans Monday classic with smoked sausage.",
cuisine: "southern",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker", "stovetop"],
servings: 6,
ingredients: [ing("kidney_beans", 3, "each"), ing("smoked_sausage", 13, "oz"), ing("celery", 1, "each"), ing("onion", 1, "each"), ing("bell_pepper", 1, "each"), ing("cajun_seasoning", 1, "tbsp"), ing("chicken_broth", 2, "cup"), ing("white_rice", 1.5, "cup")],
},
{
id: "jambalaya",
title: "One-Pot Chicken & Sausage Jambalaya",
description: "Rice simmered with the holy trinity and Cajun spice.",
cuisine: "southern",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 45,
requiredAppliances: ["stovetop"],
servings: 6,
ingredients: [ing("smoked_sausage", 13, "oz"), ing("chicken_thighs", 1, "lb"), ing("white_rice", 1.5, "cup"), ing("diced_tomatoes", 1, "each"), ing("chicken_broth", 3, "cup"), ing("onion", 1, "each"), ing("bell_pepper", 1, "each"), ing("celery", 1, "each"), ing("cajun_seasoning", 1, "tbsp")],
},
{
id: "shrimp-and-grits",
title: "Cheesy Shrimp & Grits",
description: "Creamy cheddar grits, Cajun shrimp, bacon bits.",
cuisine: "southern",
styles: ["seafood", "comfort", "quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("shrimp", 1, "lb"), ing("grits", 1, "cup"), ing("chicken_broth", 2, "cup"), ing("milk", 1, "cup"), ing("cheddar", 1, "cup"), ing("butter", 2, "tbsp"), ing("bacon", 4, "oz"), ing("cajun_seasoning", 2, "tsp"), ing("green_onions", 1, "each")],
},
{
id: "air-fryer-tenders",
title: "Air Fryer Chicken Tenders & Slaw",
description: "Crunchy breaded tenders with a tangy yogurt slaw.",
cuisine: "southern",
styles: ["comfort", "quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["air_fryer"],
servings: 4,
ingredients: [ing("chicken_breast", 1.5, "lb"), ing("breadcrumbs", 1, "cup"), ing("eggs", 2, "each"), ing("paprika", 1, "tsp"), ing("coleslaw_mix", 14, "oz"), ing("greek_yogurt", 0.5, "cup"), ing("lemon", 1, "each")],
},
// ===== Caribbean =========================================================
{
id: "jerk-chicken-rice-peas",
title: "Jerk Chicken with Rice & Peas",
description: "Spicy roasted thighs with coconut kidney-bean rice.",
cuisine: "caribbean",
styles: ["comfort"],
kind: "main",
prepTime: 45,
requiredAppliances: ["oven", "stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 2, "lb"), ing("jerk_seasoning", 2, "tbsp"), ing("lime", 1, "each"), ing("white_rice", 1.5, "cup"), ing("kidney_beans", 1, "each"), ing("coconut_milk", 400, "ml"), ing("green_onions", 1, "each")],
},
{
id: "pineapple-pork",
title: "Slow Cooker Caribbean Pineapple Pork",
description: "Sweet-spicy shredded pork over rice and black beans.",
cuisine: "caribbean",
styles: ["meal_prep", "comfort"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker", "stovetop"],
servings: 6,
ingredients: [ing("pork_shoulder", 3, "lb"), ing("pineapple", 1, "each"), ing("jerk_seasoning", 1, "tbsp"), ing("brown_sugar", 2, "tbsp"), ing("soy_sauce", 2, "tbsp"), ing("onion", 1, "each"), ing("white_rice", 2, "cup"), ing("black_beans", 2, "each")],
},
{
id: "sweet-potato-bean-stew",
title: "Caribbean Sweet Potato & Black Bean Stew",
description: "Coconut-curry stew, hearty and meatless.",
cuisine: "caribbean",
styles: ["vegetarian", "healthy", "meal_prep"],
kind: "main",
prepTime: 35,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("sweet_potatoes", 3, "each"), ing("black_beans", 2, "each"), ing("coconut_milk", 400, "ml"), ing("diced_tomatoes", 1, "each"), ing("onion", 1, "each"), ing("curry_powder", 1, "tbsp"), ing("jerk_seasoning", 1, "tsp"), ing("spinach", 3, "oz"), ing("white_rice", 1.5, "cup")],
},
// ===== More Mexican ======================================================
{
id: "chicken-enchiladas",
title: "Cheesy Chicken Enchiladas",
description: "Rolled, sauced, baked bubbly. Freezes well.",
cuisine: "mexican",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 45,
requiredAppliances: ["stovetop", "oven"],
servings: 4,
ingredients: [ing("chicken_breast", 1.5, "lb"), ing("flour_tortillas", 8, "each"), ing("enchilada_sauce", 2, "each"), ing("cheddar", 1.5, "cup"), ing("sour_cream", 0.5, "cup"), ing("onion", 1, "each")],
},
{
id: "microwave-sweet-potato-bowls",
title: "Microwave Sweet Potato & Black Bean Bowls",
description: "Steam-in-the-microwave potatoes loaded with beans and corn.",
cuisine: "mexican",
styles: ["vegetarian", "quick", "healthy"],
kind: "main",
prepTime: 15,
requiredAppliances: ["microwave"],
servings: 4,
ingredients: [ing("sweet_potatoes", 4, "each"), ing("black_beans", 1, "each"), ing("frozen_corn", 6, "oz"), ing("salsa", 0.5, "cup"), ing("greek_yogurt", 0.5, "cup"), ing("cumin", 1, "tsp"), ing("lime", 1, "each")],
},
// ===== Rice cooker & blender ==============================================
{
id: "rice-cooker-burrito-rice",
title: "Rice Cooker Burrito Bowl Rice",
description: "Rice, beans, corn and salsa cook together; top with cheese.",
cuisine: "mexican",
styles: ["vegetarian", "quick", "meal_prep"],
kind: "main",
prepTime: 10,
requiredAppliances: ["rice_cooker"],
servings: 4,
ingredients: [ing("white_rice", 2, "cup"), ing("black_beans", 1, "each"), ing("salsa", 1, "cup"), ing("taco_seasoning", 1, "each"), ing("frozen_corn", 6, "oz"), ing("cheddar", 1, "cup"), ing("cilantro", 1, "each")],
},
{
id: "rice-cooker-sausage-rice",
title: "Rice Cooker Cajun Sausage & Veggie Rice",
description: "Dump-and-press jambalaya shortcut.",
cuisine: "southern",
styles: ["comfort", "quick"],
kind: "main",
prepTime: 10,
requiredAppliances: ["rice_cooker"],
servings: 4,
ingredients: [ing("white_rice", 2, "cup"), ing("smoked_sausage", 13, "oz"), ing("frozen_mixed_veg", 10, "oz"), ing("chicken_broth", 2.5, "cup"), ing("cajun_seasoning", 2, "tsp"), ing("onion", 1, "each")],
},
{
id: "tomato-soup-grilled-cheese",
title: "Creamy Tomato Soup & Grilled Cheese",
description: "Simmer, blend silky, dunk a crispy sandwich.",
cuisine: "american",
styles: ["comfort", "vegetarian"],
kind: "main",
prepTime: 30,
requiredAppliances: ["stovetop", "blender"],
servings: 4,
ingredients: [ing("crushed_tomatoes", 2, "each"), ing("onion", 1, "each"), ing("butter", 4, "tbsp"), ing("heavy_cream", 0.5, "cup"), ing("fresh_basil", 1, "each"), ing("sandwich_bread", 8, "each"), ing("cheddar", 1.5, "cup")],
},
{
id: "blender-black-bean-soup",
title: "Blender Black Bean Soup",
description: "Half-blended for body, topped with sour cream and lime.",
cuisine: "mexican",
styles: ["vegetarian", "healthy", "meal_prep"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop", "blender"],
servings: 4,
ingredients: [ing("black_beans", 4, "each"), ing("onion", 1, "each"), ing("salsa", 1, "cup"), ing("cumin", 2, "tsp"), ing("sour_cream", 0.5, "cup"), ing("lime", 1, "each"), ing("cilantro", 1, "each")],
},
];

View file

@ -0,0 +1,307 @@
import type { RecipeData } from "@/app/types";
import { ing } from "./ing";
// Mains: Indian, Chinese, Japanese, Korean, Thai
export const MAINS_ASIA: RecipeData[] = [
{
id: "chicken-fried-rice",
title: "Chicken Fried Rice",
description: "Best with day-old rice. Great leftover-buster.",
cuisine: "chinese",
styles: ["quick", "comfort"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("white_rice", 2, "cup"), ing("chicken_breast", 1, "lb"), ing("eggs", 3, "each"), ing("frozen_mixed_veg", 10, "oz"), ing("soy_sauce", 3, "tbsp"), ing("green_onions", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("vegetable_oil", 2, "tbsp")],
},
{
id: "honey-garlic-stir-fry",
title: "Honey Garlic Chicken Stir-Fry",
description: "Sticky-sweet sauce, crisp veggies, over rice.",
cuisine: "chinese",
styles: ["quick", "healthy"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 1.5, "lb"), ing("frozen_broccoli", 12, "oz"), ing("carrots", 0.5, "lb"), ing("soy_sauce", 0.33, "cup"), ing("honey", 3, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("cornstarch", 1, "tbsp"), ing("white_rice", 1.5, "cup")],
},
{
id: "peanut-noodles-tofu",
title: "Peanut Noodles with Crispy Tofu",
description: "Spaghetti stands in for lo mein. Nobody can tell.",
cuisine: "thai",
styles: ["vegetarian", "quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("spaghetti", 12, "oz"), ing("tofu", 14, "oz"), ing("peanut_butter", 0.5, "cup"), ing("soy_sauce", 3, "tbsp"), ing("honey", 2, "tbsp"), ing("carrots", 0.5, "lb"), ing("green_onions", 1, "each"), ing("lime", 1, "each")],
},
{
id: "chicken-curry",
title: "Coconut Chicken Curry",
description: "Mild, creamy, better the next day.",
cuisine: "indian",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 35,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 1.5, "lb"), ing("coconut_milk", 400, "ml"), ing("curry_powder", 2, "tbsp"), ing("onion", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("diced_tomatoes", 1, "each"), ing("white_rice", 1.5, "cup")],
},
{
id: "red-lentil-dal",
title: "Red Lentil Dal",
description: "Protein-packed and almost free.",
cuisine: "indian",
styles: ["vegetarian", "healthy", "meal_prep"],
kind: "main",
prepTime: 30,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("red_lentils", 1.5, "cup"), ing("coconut_milk", 200, "ml"), ing("diced_tomatoes", 1, "each"), ing("onion", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("curry_powder", 1, "tbsp"), ing("cumin", 1, "tsp"), ing("white_rice", 1.5, "cup"), ing("spinach", 3, "oz")],
},
{
id: "chickpea-spinach-curry",
title: "Chickpea & Spinach Curry",
description: "Pantry curry ready in 20 minutes.",
cuisine: "indian",
styles: ["vegetarian", "quick", "healthy"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chickpeas", 2, "each"), ing("coconut_milk", 400, "ml"), ing("spinach", 5, "oz"), ing("curry_powder", 1.5, "tbsp"), ing("onion", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("white_rice", 1.5, "cup")],
},
// ===== Chinese ===========================================================
{
id: "beef-and-broccoli",
title: "Ground Beef & Broccoli",
description: "Takeout flavor on a ground-beef budget.",
cuisine: "chinese",
styles: ["quick", "comfort"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("ground_beef", 1, "lb"), ing("frozen_broccoli", 24, "oz"), ing("soy_sauce", 0.25, "cup"), ing("brown_sugar", 2, "tbsp"), ing("ginger_paste", 1, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("cornstarch", 1, "tbsp"), ing("sesame_oil", 1, "tsp"), ing("white_rice", 1.5, "cup")],
},
{
id: "kung-pao-chicken",
title: "Kung Pao Chicken",
description: "Sweet, tangy, a little spicy, with roasted peanuts.",
cuisine: "chinese",
styles: ["quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 1.5, "lb"), ing("peanuts", 0.5, "cup"), ing("bell_pepper", 2, "each"), ing("soy_sauce", 3, "tbsp"), ing("rice_vinegar", 1, "tbsp"), ing("brown_sugar", 1, "tbsp"), ing("cornstarch", 1, "tbsp"), ing("sriracha", 1, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("green_onions", 1, "each"), ing("white_rice", 1.5, "cup")],
},
{
id: "pork-lo-mein",
title: "Pork Lo Mein",
description: "Spaghetti, ground pork and cabbage in savory sauce.",
cuisine: "chinese",
styles: ["quick", "comfort"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("spaghetti", 12, "oz"), ing("ground_pork", 1, "lb"), ing("coleslaw_mix", 8, "oz"), ing("carrots", 0.5, "lb"), ing("soy_sauce", 0.25, "cup"), ing("sesame_oil", 1, "tbsp"), ing("brown_sugar", 1, "tbsp"), ing("ginger_paste", 1, "tsp"), ing("green_onions", 1, "each")],
},
// ===== Japanese ==========================================================
{
id: "teriyaki-chicken-bowls",
title: "Air Fryer Teriyaki Chicken Bowls",
description: "Glazed chicken, rice, broccoli. Meal-prep hero.",
cuisine: "japanese",
styles: ["healthy", "meal_prep", "quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["air_fryer", "stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 1.5, "lb"), ing("teriyaki_sauce", 0.5, "cup"), ing("white_rice", 1.5, "cup"), ing("frozen_broccoli", 12, "oz"), ing("green_onions", 1, "each")],
},
{
id: "japanese-curry",
title: "Japanese Chicken Curry",
description: "Thick, mild, slightly sweet curry over rice.",
cuisine: "japanese",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 40,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 1, "lb"), ing("potatoes", 1, "lb"), ing("carrots", 0.5, "lb"), ing("onion", 1, "each"), ing("curry_powder", 2, "tbsp"), ing("flour", 3, "tbsp"), ing("butter", 3, "tbsp"), ing("chicken_broth", 3, "cup"), ing("honey", 1, "tbsp"), ing("soy_sauce", 1, "tbsp"), ing("white_rice", 1.5, "cup")],
},
{
id: "oyakodon",
title: "Oyakodon (Chicken & Egg Rice Bowl)",
description: "Chicken and onions simmered in savory broth, set with egg.",
cuisine: "japanese",
styles: ["quick", "comfort"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 1, "lb"), ing("eggs", 4, "each"), ing("onion", 1, "each"), ing("chicken_broth", 1, "cup"), ing("soy_sauce", 3, "tbsp"), ing("brown_sugar", 1, "tbsp"), ing("green_onions", 1, "each"), ing("white_rice", 1.5, "cup")],
},
// ===== Korean ============================================================
{
id: "bulgogi-beef-bowls",
title: "Ground Beef Bulgogi Bowls",
description: "Sweet-savory beef over rice with crisp cucumber.",
cuisine: "korean",
styles: ["quick", "meal_prep"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("ground_beef", 1, "lb"), ing("soy_sauce", 0.25, "cup"), ing("brown_sugar", 2, "tbsp"), ing("sesame_oil", 1, "tbsp"), ing("ginger_paste", 1, "tsp"), ing("minced_garlic", 1, "tbsp"), ing("cucumber", 1, "each"), ing("green_onions", 1, "each"), ing("white_rice", 1.5, "cup")],
},
{
id: "bibimbap",
title: "Bibimbap Rice Bowls",
description: "Rice topped with veggies, beef, fried egg and gochujang.",
cuisine: "korean",
styles: ["healthy"],
kind: "main",
prepTime: 35,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("ground_beef", 0.5, "lb"), ing("white_rice", 1.5, "cup"), ing("spinach", 5, "oz"), ing("carrots", 0.5, "lb"), ing("eggs", 4, "each"), ing("gochujang", 3, "tbsp"), ing("sesame_oil", 1, "tbsp"), ing("soy_sauce", 2, "tbsp")],
},
{
id: "kimchi-fried-rice",
title: "Kimchi Fried Rice",
description: "Best use of leftover rice. Topped with a runny egg.",
cuisine: "korean",
styles: ["quick", "comfort"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("white_rice", 2, "cup"), ing("kimchi", 1.5, "cup"), ing("eggs", 4, "each"), ing("smoked_sausage", 6.5, "oz"), ing("gochujang", 1, "tbsp"), ing("sesame_oil", 1, "tbsp"), ing("green_onions", 1, "each")],
},
{
id: "gochujang-pork",
title: "Slow Cooker Gochujang Pulled Pork",
description: "Spicy-sweet Korean pork with crunchy slaw over rice.",
cuisine: "korean",
styles: ["meal_prep", "comfort"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker", "stovetop"],
servings: 6,
ingredients: [ing("pork_shoulder", 3, "lb"), ing("gochujang", 0.33, "cup"), ing("soy_sauce", 0.25, "cup"), ing("brown_sugar", 3, "tbsp"), ing("ginger_paste", 1, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("white_rice", 2, "cup"), ing("coleslaw_mix", 8, "oz")],
},
{
id: "kimchi-ramen",
title: "Microwave Kimchi Ramen",
description: "Instant ramen upgraded with egg, veg and kimchi.",
cuisine: "korean",
styles: ["quick"],
kind: "main",
prepTime: 8,
requiredAppliances: ["microwave"],
servings: 4,
ingredients: [ing("instant_ramen", 4, "each"), ing("eggs", 4, "each"), ing("frozen_mixed_veg", 8, "oz"), ing("kimchi", 0.5, "cup"), ing("green_onions", 1, "each"), ing("sesame_oil", 1, "tsp")],
},
// ===== Thai ==============================================================
{
id: "thai-red-curry",
title: "Thai Red Curry Chicken",
description: "Coconut red curry with peppers and basil.",
cuisine: "thai",
styles: ["comfort"],
kind: "main",
prepTime: 30,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("chicken_thighs", 1.5, "lb"), ing("red_curry_paste", 3, "tbsp"), ing("coconut_milk", 400, "ml"), ing("bell_pepper", 1, "each"), ing("carrots", 0.5, "lb"), ing("fish_sauce", 1, "tbsp"), ing("brown_sugar", 1, "tbsp"), ing("lime", 1, "each"), ing("fresh_basil", 1, "each"), ing("white_rice", 1.5, "cup")],
},
{
id: "pad-krapow",
title: "Pad Kra Pao (Thai Basil Pork)",
description: "Fiery basil pork with a crispy fried egg on rice.",
cuisine: "thai",
styles: ["quick"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("ground_pork", 1, "lb"), ing("fresh_basil", 1, "each"), ing("fish_sauce", 1.5, "tbsp"), ing("soy_sauce", 1, "tbsp"), ing("brown_sugar", 1, "tsp"), ing("jalapeno", 2, "each"), ing("minced_garlic", 1, "tbsp"), ing("eggs", 4, "each"), ing("white_rice", 1.5, "cup")],
},
{
id: "thai-peanut-sheet-pan",
title: "Sheet-Pan Thai Peanut Chicken",
description: "Peanut-lime chicken roasted with sweet potato and broccoli.",
cuisine: "thai",
styles: ["meal_prep", "healthy"],
kind: "main",
prepTime: 40,
requiredAppliances: ["oven"],
servings: 4,
ingredients: [ing("chicken_breast", 1.5, "lb"), ing("peanut_butter", 0.33, "cup"), ing("soy_sauce", 3, "tbsp"), ing("lime", 1, "each"), ing("honey", 2, "tbsp"), ing("sriracha", 1, "tbsp"), ing("sweet_potatoes", 2, "each"), ing("frozen_broccoli", 12, "oz")],
},
// ===== More Indian =======================================================
{
id: "slow-cooker-butter-chicken",
title: "Slow Cooker Butter Chicken",
description: "Rich tomato-cream sauce, set and forget.",
cuisine: "indian",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker", "stovetop"],
servings: 6,
ingredients: [ing("chicken_thighs", 2, "lb"), ing("tomato_paste", 2, "each"), ing("heavy_cream", 0.75, "cup"), ing("butter", 3, "tbsp"), ing("curry_powder", 2, "tbsp"), ing("ginger_paste", 1, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("onion", 1, "each"), ing("white_rice", 2, "cup")],
},
// ===== Rice cooker one-pots ===============================================
{
id: "rice-cooker-chicken-rice",
title: "Rice Cooker Ginger Chicken & Rice",
description: "Hainanese-style: chicken steams on top while the rice cooks in broth.",
cuisine: "chinese",
styles: ["comfort", "healthy"],
kind: "main",
prepTime: 10,
requiredAppliances: ["rice_cooker"],
servings: 4,
ingredients: [ing("chicken_thighs", 1.5, "lb"), ing("white_rice", 2, "cup"), ing("chicken_broth", 2.5, "cup"), ing("ginger_paste", 1, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("cucumber", 1, "each"), ing("soy_sauce", 3, "tbsp"), ing("sriracha", 1, "tbsp"), ing("green_onions", 1, "each")],
},
{
id: "takikomi-gohan",
title: "Rice Cooker Takikomi Gohan",
description: "Japanese seasoned rice with chicken and carrots, all in one pot.",
cuisine: "japanese",
styles: ["healthy", "meal_prep"],
kind: "main",
prepTime: 10,
requiredAppliances: ["rice_cooker"],
servings: 4,
ingredients: [ing("white_rice", 2, "cup"), ing("chicken_thighs", 0.75, "lb"), ing("carrots", 0.5, "lb"), ing("soy_sauce", 3, "tbsp"), ing("brown_sugar", 1, "tbsp"), ing("chicken_broth", 2, "cup"), ing("green_onions", 1, "each")],
},
{
id: "rice-cooker-kitchari",
title: "Rice Cooker Kitchari",
description: "Comforting rice-and-lentil porridge with warm spices.",
cuisine: "indian",
styles: ["vegetarian", "healthy", "comfort"],
kind: "main",
prepTime: 10,
requiredAppliances: ["rice_cooker"],
servings: 4,
ingredients: [ing("white_rice", 1, "cup"), ing("red_lentils", 1, "cup"), ing("curry_powder", 1, "tbsp"), ing("cumin", 1, "tsp"), ing("diced_tomatoes", 1, "each"), ing("spinach", 3, "oz"), ing("ginger_paste", 1, "tsp"), ing("butter", 2, "tbsp")],
},
];

View file

@ -0,0 +1,181 @@
import type { RecipeData } from "@/app/types";
import { ing } from "./ing";
// Mains: Italian, Mediterranean, Middle Eastern
export const MAINS_MEDITERRANEAN: RecipeData[] = [
{
id: "spaghetti-meat-sauce",
title: "Spaghetti with Meat Sauce",
description: "The budget classic.",
cuisine: "italian",
styles: ["comfort", "quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("spaghetti", 1, "lb"), ing("ground_beef", 1, "lb"), ing("marinara", 1, "each"), ing("onion", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("parmesan", 0.25, "cup")],
},
{
id: "baked-ziti",
title: "Turkey Baked Ziti",
description: "Bubbly, cheesy, feeds a crowd.",
cuisine: "italian",
styles: ["comfort", "meal_prep"],
kind: "main",
prepTime: 45,
requiredAppliances: ["stovetop", "oven"],
servings: 6,
ingredients: [ing("penne", 1, "lb"), ing("ground_turkey", 1, "lb"), ing("marinara", 1, "each"), ing("crushed_tomatoes", 1, "each"), ing("mozzarella", 2, "cup"), ing("parmesan", 0.5, "cup"), ing("italian_seasoning", 1, "tsp")],
},
{
id: "chicken-alfredo",
title: "Chicken Broccoli Alfredo",
description: "Creamy garlic parmesan sauce from scratch.",
cuisine: "italian",
styles: ["comfort"],
kind: "main",
prepTime: 30,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("penne", 1, "lb"), ing("chicken_breast", 1, "lb"), ing("heavy_cream", 1.5, "cup"), ing("parmesan", 1, "cup"), ing("butter", 3, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("frozen_broccoli", 12, "oz")],
},
{
id: "air-fryer-meatballs",
title: "Air Fryer Turkey Meatballs & Spaghetti",
description: "Meatballs crisp in 12 minutes while the pasta boils.",
cuisine: "italian",
styles: ["comfort", "healthy"],
kind: "main",
prepTime: 30,
requiredAppliances: ["air_fryer", "stovetop"],
servings: 4,
ingredients: [ing("ground_turkey", 1, "lb"), ing("breadcrumbs", 0.5, "cup"), ing("eggs", 1, "each"), ing("parmesan", 0.25, "cup"), ing("italian_seasoning", 1, "tsp"), ing("spaghetti", 1, "lb"), ing("marinara", 1, "each")],
},
{
id: "med-chicken-bowls",
title: "Lemon Chicken Rice Bowls",
description: "Roasted chicken and peppers with garlicky yogurt sauce.",
cuisine: "mediterranean",
styles: ["healthy", "meal_prep"],
kind: "main",
prepTime: 35,
requiredAppliances: ["oven", "stovetop"],
servings: 4,
ingredients: [ing("chicken_breast", 1.5, "lb"), ing("white_rice", 1.5, "cup"), ing("greek_yogurt", 1, "cup"), ing("lemon", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("bell_pepper", 2, "each"), ing("olive_oil", 2, "tbsp"), ing("paprika", 1, "tsp"), ing("cumin", 1, "tsp")],
},
{
id: "chickpea-salad",
title: "Lemony Chickpea Spinach Salad",
description: "No-cook lunch that holds up for days.",
cuisine: "mediterranean",
styles: ["vegetarian", "quick", "healthy", "meal_prep"],
kind: "main",
prepTime: 10,
requiredAppliances: [],
servings: 4,
ingredients: [ing("chickpeas", 2, "each"), ing("spinach", 5, "oz"), ing("bell_pepper", 1, "each"), ing("lemon", 2, "each"), ing("olive_oil", 3, "tbsp"), ing("parmesan", 0.25, "cup")],
},
{
id: "slow-cooker-lentil-soup",
title: "Slow Cooker Lentil Soup",
description: "Hearty, cheap, freezer-friendly.",
cuisine: "mediterranean",
styles: ["vegetarian", "healthy", "meal_prep", "comfort"],
kind: "main",
prepTime: 15,
requiredAppliances: ["slow_cooker"],
servings: 6,
ingredients: [ing("red_lentils", 1.5, "cup"), ing("carrots", 0.5, "lb"), ing("celery", 1, "each"), ing("onion", 1, "each"), ing("diced_tomatoes", 1, "each"), ing("chicken_broth", 6, "cup"), ing("cumin", 1, "tsp"), ing("lemon", 1, "each")],
},
// ===== Middle Eastern ====================================================
{
id: "chicken-shawarma-pitas",
title: "Sheet-Pan Chicken Shawarma Pitas",
description: "Warm-spiced chicken, garlic yogurt, cucumber and tomato.",
cuisine: "middle_eastern",
styles: ["healthy", "meal_prep"],
kind: "main",
prepTime: 35,
requiredAppliances: ["oven"],
servings: 4,
ingredients: [ing("chicken_thighs", 1.5, "lb"), ing("pita_bread", 4, "each"), ing("greek_yogurt", 1, "cup"), ing("cumin", 2, "tsp"), ing("paprika", 2, "tsp"), ing("cinnamon", 0.25, "tsp"), ing("lemon", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("cucumber", 1, "each"), ing("roma_tomatoes", 2, "each"), ing("olive_oil", 2, "tbsp")],
},
{
id: "air-fryer-falafel",
title: "Air Fryer Falafel Bowls",
description: "Crispy chickpea falafel without the deep fryer.",
cuisine: "middle_eastern",
styles: ["vegetarian", "healthy"],
kind: "main",
prepTime: 30,
requiredAppliances: ["air_fryer"],
servings: 4,
ingredients: [ing("chickpeas", 2, "each"), ing("flour", 3, "tbsp"), ing("onion", 1, "each"), ing("cilantro", 1, "each"), ing("cumin", 2, "tsp"), ing("minced_garlic", 1, "tbsp"), ing("greek_yogurt", 0.5, "cup"), ing("cucumber", 1, "each"), ing("roma_tomatoes", 2, "each"), ing("pita_bread", 4, "each"), ing("olive_oil", 1, "tbsp")],
},
{
id: "mujadara",
title: "Mujadara (Lentils, Rice & Crispy Onions)",
description: "Ancient, dirt-cheap, deeply savory.",
cuisine: "middle_eastern",
styles: ["vegetarian", "meal_prep", "healthy"],
kind: "main",
prepTime: 45,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("red_lentils", 1, "cup"), ing("white_rice", 1, "cup"), ing("onion", 3, "each"), ing("cumin", 1, "tsp"), ing("olive_oil", 3, "tbsp"), ing("greek_yogurt", 0.5, "cup"), ing("cucumber", 1, "each")],
},
// ===== More Mediterranean & Italian ======================================
{
id: "greek-salad-pitas",
title: "Greek Chickpea Salad Pitas",
description: "No-cook lunch: crunchy veg, feta, lemon.",
cuisine: "mediterranean",
styles: ["vegetarian", "quick", "healthy"],
kind: "main",
prepTime: 10,
requiredAppliances: [],
servings: 4,
ingredients: [ing("pita_bread", 4, "each"), ing("chickpeas", 1, "each"), ing("cucumber", 1, "each"), ing("roma_tomatoes", 3, "each"), ing("feta", 0.5, "cup"), ing("lemon", 1, "each"), ing("olive_oil", 2, "tbsp")],
},
{
id: "pasta-e-fagioli",
title: "Pasta e Fagioli",
description: "Italian bean & pasta soup. Cheap, filling, freezable.",
cuisine: "italian",
styles: ["comfort", "healthy", "meal_prep"],
kind: "main",
prepTime: 35,
requiredAppliances: ["stovetop"],
servings: 6,
ingredients: [ing("elbow_macaroni", 8, "oz"), ing("kidney_beans", 2, "each"), ing("diced_tomatoes", 1, "each"), ing("chicken_broth", 4, "cup"), ing("carrots", 0.5, "lb"), ing("celery", 1, "each"), ing("onion", 1, "each"), ing("italian_seasoning", 1, "tsp"), ing("parmesan", 0.25, "cup")],
},
// ===== Blender ===========================================================
{
id: "gazpacho",
title: "Gazpacho with Chickpea Pitas",
description: "Chilled blended tomato soup. Zero cooking, peak summer.",
cuisine: "mediterranean",
styles: ["vegetarian", "healthy", "quick"],
kind: "main",
prepTime: 15,
requiredAppliances: ["blender"],
servings: 4,
ingredients: [ing("roma_tomatoes", 8, "each"), ing("cucumber", 1, "each"), ing("bell_pepper", 1, "each"), ing("onion", 0.5, "each"), ing("minced_garlic", 1, "tsp"), ing("olive_oil", 3, "tbsp"), ing("lemon", 1, "each"), ing("chickpeas", 1, "each"), ing("pita_bread", 4, "each")],
},
{
id: "budget-pesto-pasta",
title: "Budget Spinach Pesto Pasta",
description: "Spinach stretches the basil; peanuts stand in for pine nuts.",
cuisine: "italian",
styles: ["vegetarian", "quick"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop", "blender"],
servings: 4,
ingredients: [ing("spaghetti", 1, "lb"), ing("fresh_basil", 1, "each"), ing("spinach", 3, "oz"), ing("parmesan", 0.5, "cup"), ing("olive_oil", 0.33, "cup"), ing("peanuts", 0.25, "cup"), ing("minced_garlic", 1, "tsp"), ing("lemon", 1, "each")],
},
];

170
app/lib/recipes/seafood.ts Normal file
View file

@ -0,0 +1,170 @@
import type { RecipeData } from "@/app/types";
import { ing } from "./ing";
// Seafood (all cuisines). Every recipe here is tagged with the "seafood" style,
// so the 🐟 Seafood chip in the config panel and meal picker finds them.
//
// Budget note: frozen fillets (pollock, tilapia) and canned tuna/clams are the
// cheap end; salmon and shrimp are the splurge.
export const SEAFOOD: RecipeData[] = [
// ===== Budget: canned & frozen white fish ================================
{
id: "tuna-melts",
title: "Crispy Tuna Melts",
description: "Diner-style tuna salad on buttery griddled bread.",
cuisine: "american",
styles: ["seafood", "comfort", "quick"],
kind: "main",
prepTime: 15,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("canned_tuna", 3, "each"), ing("mayo", 0.33, "cup"), ing("celery", 1, "each"), ing("cheddar", 1, "cup"), ing("sandwich_bread", 8, "each"), ing("butter", 2, "tbsp")],
},
{
id: "tuna-chickpea-pitas",
title: "Lemony Tuna & Chickpea Pitas",
description: "No-cook Mediterranean lunch, packed with protein.",
cuisine: "mediterranean",
styles: ["seafood", "quick", "healthy"],
kind: "main",
prepTime: 10,
requiredAppliances: [],
servings: 4,
ingredients: [ing("canned_tuna", 2, "each"), ing("chickpeas", 1, "each"), ing("cucumber", 1, "each"), ing("roma_tomatoes", 2, "each"), ing("lemon", 1, "each"), ing("olive_oil", 2, "tbsp"), ing("pita_bread", 4, "each")],
},
{
id: "baja-fish-tacos",
title: "Baja Fish Tacos",
description: "Seasoned seared pollock, crunchy slaw, lime crema.",
cuisine: "mexican",
styles: ["seafood", "quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("pollock", 1, "lb"), ing("taco_seasoning", 1, "each"), ing("flour_tortillas", 8, "each"), ing("coleslaw_mix", 8, "oz"), ing("sour_cream", 0.33, "cup"), ing("lime", 1, "each"), ing("salsa", 0.5, "cup")],
},
{
id: "old-bay-tilapia",
title: "Air Fryer Old Bay Tilapia",
description: "Flaky lemon-butter fish with rice and broccoli.",
cuisine: "southern",
styles: ["seafood", "healthy", "quick"],
kind: "main",
prepTime: 20,
requiredAppliances: ["air_fryer", "stovetop"],
servings: 4,
ingredients: [ing("tilapia", 1.5, "lb"), ing("old_bay", 2, "tsp"), ing("butter", 2, "tbsp"), ing("lemon", 1, "each"), ing("white_rice", 1.5, "cup"), ing("frozen_broccoli", 12, "oz")],
},
{
id: "jerk-tilapia-mango",
title: "Jerk Tilapia with Mango Salsa",
description: "Spicy baked fish, cool mango salsa, rice.",
cuisine: "caribbean",
styles: ["seafood", "healthy"],
kind: "main",
prepTime: 30,
requiredAppliances: ["oven", "stovetop"],
servings: 4,
ingredients: [ing("tilapia", 1.5, "lb"), ing("jerk_seasoning", 1, "tbsp"), ing("frozen_mango", 1, "cup"), ing("cilantro", 1, "each"), ing("lime", 1, "each"), ing("onion", 0.5, "each"), ing("white_rice", 1.5, "cup")],
},
{
id: "coconut-fish-curry",
title: "Coconut Fish Curry",
description: "Goan-inspired: tender white fish in a tomato-coconut sauce.",
cuisine: "indian",
styles: ["seafood", "comfort"],
kind: "main",
prepTime: 30,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("pollock", 1.5, "lb"), ing("coconut_milk", 400, "ml"), ing("curry_powder", 2, "tbsp"), ing("diced_tomatoes", 1, "each"), ing("onion", 1, "each"), ing("ginger_paste", 1, "tsp"), ing("white_rice", 1.5, "cup")],
},
{
id: "linguine-clams",
title: "Linguine with Clam Sauce",
description: "Garlicky white-wine-style sauce from canned clams.",
cuisine: "italian",
styles: ["seafood", "quick"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("spaghetti", 1, "lb"), ing("canned_clams", 2, "each"), ing("butter", 3, "tbsp"), ing("olive_oil", 2, "tbsp"), ing("minced_garlic", 1, "tbsp"), ing("lemon", 1, "each"), ing("parmesan", 0.25, "cup")],
},
{
id: "california-sushi-bowls",
title: "California Sushi Bowls",
description: "All the roll, none of the rolling: crab, avocado, spicy mayo.",
cuisine: "japanese",
styles: ["seafood", "quick"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("imitation_crab", 8, "oz"), ing("white_rice", 2, "cup"), ing("cucumber", 1, "each"), ing("avocado", 2, "each"), ing("seaweed_snacks", 2, "each"), ing("rice_vinegar", 3, "tbsp"), ing("mayo", 3, "tbsp"), ing("sriracha", 1, "tbsp"), ing("soy_sauce", 2, "tbsp")],
},
// ===== Splurge: shrimp & salmon ============================================
{
id: "shrimp-fried-rice",
title: "Shrimp Fried Rice",
description: "Takeout favorite in 20 minutes.",
cuisine: "chinese",
styles: ["seafood", "quick"],
kind: "main",
prepTime: 20,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("shrimp", 0.75, "lb"), ing("white_rice", 2, "cup"), ing("eggs", 3, "each"), ing("frozen_mixed_veg", 10, "oz"), ing("soy_sauce", 3, "tbsp"), ing("green_onions", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("vegetable_oil", 2, "tbsp")],
},
{
id: "thai-shrimp-curry",
title: "Thai Coconut Shrimp Curry",
description: "Red curry, sweet peppers, basil, quick-cooking shrimp.",
cuisine: "thai",
styles: ["seafood", "comfort"],
kind: "main",
prepTime: 25,
requiredAppliances: ["stovetop"],
servings: 4,
ingredients: [ing("shrimp", 1, "lb"), ing("red_curry_paste", 2, "tbsp"), ing("coconut_milk", 400, "ml"), ing("bell_pepper", 1, "each"), ing("fish_sauce", 1, "tbsp"), ing("lime", 1, "each"), ing("fresh_basil", 1, "each"), ing("white_rice", 1.5, "cup")],
},
{
id: "cajun-shrimp-boil",
title: "Sheet-Pan Cajun Shrimp Boil",
description: "Shrimp, sausage, potatoes and corn in Old Bay butter.",
cuisine: "southern",
styles: ["seafood", "comfort"],
kind: "main",
prepTime: 40,
requiredAppliances: ["oven"],
servings: 4,
ingredients: [ing("shrimp", 1, "lb"), ing("smoked_sausage", 13, "oz"), ing("potatoes", 1.5, "lb"), ing("frozen_corn", 12, "oz"), ing("old_bay", 1, "tbsp"), ing("butter", 4, "tbsp"), ing("lemon", 1, "each")],
},
{
id: "lemon-garlic-salmon",
title: "Sheet-Pan Lemon Garlic Salmon",
description: "Roasted salmon with crispy potatoes and broccoli.",
cuisine: "mediterranean",
styles: ["seafood", "healthy", "meal_prep"],
kind: "main",
prepTime: 35,
requiredAppliances: ["oven"],
servings: 4,
ingredients: [ing("salmon", 1.5, "lb"), ing("potatoes", 1.5, "lb"), ing("frozen_broccoli", 12, "oz"), ing("lemon", 1, "each"), ing("minced_garlic", 1, "tbsp"), ing("olive_oil", 2, "tbsp"), ing("paprika", 1, "tsp")],
},
{
id: "rice-cooker-teriyaki-salmon",
title: "Rice Cooker Teriyaki Salmon & Rice",
description: "Salmon steams on top of the rice. One button, one pot.",
cuisine: "japanese",
styles: ["seafood", "healthy", "quick"],
kind: "main",
prepTime: 10,
requiredAppliances: ["rice_cooker"],
servings: 4,
ingredients: [ing("salmon", 1, "lb"), ing("white_rice", 2, "cup"), ing("teriyaki_sauce", 0.33, "cup"), ing("frozen_broccoli", 12, "oz"), ing("green_onions", 1, "each")],
},
];

View file

@ -0,0 +1,321 @@
// Directions for Cayden's Corner (recipes/caydens-corner.ts), family recipes.
// Kept in the original wording, with only
// typo fixes and small clarifications in [brackets].
export const CAYDENS_CORNER_STEPS: Record<string, string[]> = {
// --- Appetizers & Side Dishes ---------------------------------------------------
"family-buffalo-chicken-dip": [
"Heat chicken and hot pepper sauce in a skillet over medium heat until heated through, 3 to 5 minutes. Stir in cream cheese and ranch dressing. Cook and stir until well blended and warm, 3 to 5 minutes.",
"Mix in half of the Cheddar cheese, and transfer the mixture to a slow cooker. Sprinkle the remaining cheese over top.",
"Cover and cook on Low until dip is hot and bubbly, about 35 minutes.",
"Serve with celery sticks and crackers.",
],
"family-crab-stuffed-mushrooms": [
"Gather all ingredients and preheat the oven to 350°F.",
"Combine crabmeat, green onions, thyme, oregano, savory [or extra thyme], and pepper in a medium bowl. Mix in mayonnaise and ¼ cup Parmesan until well combined. Refrigerate filling until ready to use.",
"Wipe mushrooms clean with a damp towel. Remove stems. Spoon out the gills and the base of the stem, making deep cups. Discard gills and stems.",
"Fill mushroom caps with rounded teaspoonfuls of filling and place them in an ungreased shallow baking dish. Sprinkle tops with the remaining Parmesan and paprika.",
"Bake in the preheated oven until heated through, about 15 minutes.",
],
"family-hot-spinach-artichoke-dip": [
"Preheat the oven to 350°F. Lightly grease a small baking dish.",
"Mix cream cheese, mayonnaise, Parmesan, Romano, garlic, basil, garlic salt, salt, and pepper together in a medium bowl.",
"Gently stir in artichoke hearts and spinach.",
"Transfer the mixture to the prepared baking dish; top with mozzarella cheese.",
"Bake until bubbly and lightly browned, about 25 minutes.",
],
"family-maris-jalapeno-poppers": [
"Preheat the air fryer to 375°F.",
"Wash and slice the jalapeños in half lengthwise. Using a small spoon, scoop out the seeds and membranes.",
"In a bowl, mix cream cheese, garlic powder, and cheddar cheese. Fill jalapeños with the cheese mixture.",
"Optional: combine panko and melted butter in a small bowl. Dip the cream cheese side of the stuffed peppers into the crumbs to coat evenly.",
"Air fry 5–8 minutes or until golden. Cool 5–10 minutes before serving.",
],
// --- Main Dishes --------------------------------------------------------------------
"family-baked-ziti": [
"Preheat oven to 350°F. Bring a large pot of lightly salted water to a boil. Add pasta and cook 8 to 10 minutes or until al dente; drain.",
"Place sausage in a large, deep skillet. Cook over medium-high heat until evenly brown. Drain, crumble and set aside.",
"In a large saucepan, heat marinara with 1 teaspoon basil until bubbling.",
"In a medium bowl, mix together ricotta, 1 teaspoon Italian seasoning, salt and pepper.",
"Spread the bottom of a 9×13 dish with sauce. Layer half the ziti, half the sausage, half the ricotta, and half the mozzarella. Spoon half the sauce over top. Repeat layers. Sprinkle with the remaining ½ teaspoon each of basil and Italian seasoning.",
"Bake 40 to 45 minutes.",
],
"family-beef-stroganoff": [
"Sauté ground beef in a large skillet over medium heat until browned and crumbly, 5 to 10 minutes.",
"Meanwhile, cook egg noodles in lightly salted boiling water until tender yet firm to the bite, 7 to 9 minutes. Drain and set aside.",
"Drain and discard any fat from the cooked beef. Stir condensed soup and garlic powder into the beef. Simmer 10 minutes, stirring occasionally.",
"Remove beef from the heat. Add egg noodles and stir to combine.",
"Stir in sour cream and season with salt and pepper.",
],
"family-chicken-piccata": [
"Place chicken breasts in a storage bag and pound to ½-inch thickness.",
"Season both sides with cayenne, salt, and black pepper; dredge lightly in flour and shake off any excess.",
"Heat olive oil in a skillet over medium-high heat. Add chicken, reduce heat to medium, and cook until browned and cooked through, about 5 minutes per side. Remove to a plate.",
"Cook capers in the reserved oil, smashing them lightly to release brine, about 30 seconds.",
"Pour in white wine and bring to a boil, scraping up the browned bits. Cook until reduced by half.",
"Stir in lemon juice, a splash of water, and butter; cook and stir to form a thick sauce (~2 min). Reduce heat to low and add parsley.",
"Return chicken to the pan until heated through, 1 to 2 minutes. Serve with sauce spooned over the top.",
],
"family-chili": [
"Cook beef and onion in a large saucepan or sauté pan until beef is browned. Drain grease.",
"In a large stock pot, add all of the remaining ingredients (except the beans) plus 1 cup water and stir well.",
"Add the ground beef and onion mixture to the stock pot.",
"Bring to a boil, then reduce heat to low. Simmer for 1 hour, stirring occasionally.",
"Add the beans (with the liquid) and stir well. Cook 20 more minutes, stirring occasionally.",
"Serve with toppings of choice (sour cream, cheddar cheese, saltine crackers, etc.).",
],
"family-christmas-sausage-egg-casserole": [
"Preheat oven to 350°F.",
"Cook sausage in a skillet with the diced onion until both are browned. Season with salt and pepper.",
"Combine eggs, milk, hash browns, [the sausage mixture,] and ¾ of the cheddar in a large bowl and mix. [Pour into a greased baking dish.]",
"Bake 35 minutes or until eggs are done.",
"Top with remaining cheese and bake 5 more minutes.",
],
"family-chicken-cordon-bleu": [
"Mix flour and paprika together in a bowl. Pound chicken breasts to ¼-inch thickness.",
"Place one slice Swiss and one slice ham on each breast, leaving the outer ½ inch uncovered.",
"Roll chicken over the filling, dip in the flour mixture to coat on all sides, and secure with toothpicks.",
"Melt butter in a large skillet over medium-high heat. Add chicken and cook, turning occasionally, until browned on all sides, 5 to 8 minutes.",
"Add wine and bouillon. Reduce heat to low, cover, and simmer until chicken is no longer pink in the center, about 30 minutes. Transfer chicken to a warm platter and remove the toothpicks.",
"Whisk cream and cornstarch together; slowly pour into the skillet, whisking constantly. Cook, stirring often, until the sauce thickens, 3–5 minutes. Pour sauce over chicken and serve.",
],
"family-coconut-curry-shrimp": [
"Heat oil in a large nonstick saucepan over medium heat. Sauté onion, red pepper, and garlic until they begin to soften, about 3 minutes.",
"Season with cumin, coriander, and curry powder. Cook 1 more minute.",
"Stir in coconut milk, sugar, and crushed red pepper flakes. Bring to a boil, then reduce heat and simmer uncovered 2 minutes.",
"Stir in shrimp and increase heat to medium-high. Cook and stir until shrimp is cooked through, about 4 minutes.",
"Combine cornstarch with 1 tablespoon water; stir into the shrimp and cook until thickened, about 1 minute.",
"Stir in cilantro, and remove from heat.",
],
"family-creamy-southwest-chicken": [
"Heat a large skillet with oil over medium heat.",
"Cut chicken into bite-sized pieces and season with salt and pepper. Sauté until browned on both sides, adding the onion about halfway through.",
"Add garlic and cook another minute. If needed, deglaze the pan with a little water or chicken stock.",
"Add green chiles and cream, and simmer until the chicken is done and the sauce has thickened.",
"Top with shredded cheddar or jack cheese, and serve when the cheese melts.",
],
"family-crispy-pork-cutlets": [
"Cut tenderloins into 8 pieces. Place in a resealable bag and pound with a meat mallet to ½-inch thickness.",
"Season both sides with salt and pepper; sprinkle with flour, lightly coating both sides. Pour beaten eggs over the pork, turning to coat.",
"Press pork pieces firmly into a shallow bowl of panko on both sides. Cover and refrigerate 15 minutes.",
"Sauce: combine wine, lemon juice and zest, cream, minced shallot, and garlic in a pan and simmer over medium heat until reduced by about 70 percent. Whisk in butter until smooth (it thickens as it cools).",
"Heat ¼ cup oil in a large skillet over medium-high heat. Cook cutlets until no longer pink inside and well-browned, 4 to 5 minutes per side, adding more oil as needed. Drain on paper towels.",
"Serve cutlets topped with sauce.",
],
"family-curried-chicken-broccoli-rice-casserole": [
"Preheat oven to 350°F.",
"Cube and sauté chicken until completely cooked. Steam broccoli; cool and chop into small pieces.",
"Mix mayo, curry powder, cayenne, garlic powder, salt and pepper in a large bowl until well combined. Add the soups and stir until evenly mixed.",
"Spread the chicken in a 9×13 pan and cover with the broccoli. Layer the sauce on top so the chicken and broccoli are completely covered.",
"Top with the cheddar and French-fried onions. Bake 30 minutes.",
"While the casserole bakes, prepare jasmine rice. Serve over rice.",
],
"family-green-chicken-chili-soup": [
"Heat olive oil in a large pot over medium heat. Add the onion and bell pepper and cook, stirring, until softened, about 5 minutes.",
"Stir in the jalapeño and garlic and cook until fragrant, 1 more minute. Transfer to a slow cooker.",
"Stir in the chicken stock, white beans, salsa verde, green chiles, chicken, cumin, chili powder and salt.",
"Cook on high for 4 hours or low for 8 hours.",
"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.",
],
"family-indian-chicken-curry": [
"Heat olive oil in a skillet over medium heat. Sauté onion until lightly browned.",
"Stir in garlic, curry powder, cinnamon, paprika, a bay leaf, ginger, sugar, and salt. Continue stirring for 2 minutes.",
"Add chicken pieces, 1 tablespoon tomato paste, yogurt, and coconut milk.",
"Bring to a boil, reduce heat, and simmer 20 to 25 minutes.",
"Remove the bay leaf, and stir in lemon juice and cayenne. Simmer 5 more minutes.",
],
"family-japanese-glazed-chicken": [
"Place drumsticks, 2 cups water, balsamic vinegar, soy sauce, sugar, garlic, and the seeded, slit-open chili peppers in a crockpot.",
"Cook on low for 4 hours.",
"Prepare rice 20 minutes before time is up. Serve over rice.",
],
"family-loaded-baked-potato-soup": [
"Add all the ingredients to the crockpot (save a little cheese, bacon and green onion for topping) and give it a good stir. Season with pepper.",
"Put the lid on and cook on high for 4 hours.",
"Stir a few times to blend in the cream cheese. Cook another 30 minutes.",
"Pour into bowls. Top with shredded cheese, bacon bits, and green onion.",
],
"family-mango-chili-chicken": [
"Mix all the spices together in a plastic bag. Add olive oil and mix until the spices are blended into the oil.",
"Add chicken to the bag and massage until all breasts are coated with the spice mix.",
"Refrigerate 1 hour to let the spices soak into the meat.",
"Heat the grill (or a grill pan on the stovetop) to medium and cook until juices run clear.",
"Top with mango peach salsa and serve immediately.",
],
"family-marry-me-chicken": [
"Mix ½ tsp salt, garlic powder, onion powder, and ¼ tsp black pepper. Season chicken on both sides.",
"Heat a large skillet over medium-high heat with olive oil and butter. Sear chicken until golden brown; take the pan off the heat and set the chicken aside, keeping the oil and butter in the pan.",
"Whisk chicken broth and cornstarch until no lumps remain; pour into the bottom of the slow cooker. Whisk in cream, garlic, Italian seasoning, remaining salt and pepper, chili oil, and paprika.",
"Place the chicken in the slow cooker and pour the pan oil and butter on top. Top with sun-dried tomatoes. Cook on low 6–7 hours.",
"Remove the chicken and whisk the Parmesan into the sauce. Return the chicken, cover with sauce, and top with chopped basil to serve.",
],
"family-prime-rib": [
"Salt the roast generously on all sides. Let sit in the refrigerator at least 2 hours.",
"Take the meat out of the fridge and season with black pepper. Mix mustard, garlic, chopped thyme, and rosemary; spread evenly over the roast and let it sit on the counter 1 hour.",
"Preheat oven to 250°F. Roast on the middle rack until the internal temperature reaches 120°F for medium rare (130°F for medium), about 3 to 3½ hours depending on size.",
"Rest the roast 30 minutes. Turn the oven up to 500°F and return the roast for 10–15 minutes to sear.",
"Horseradish cream sauce: mix horseradish, sour cream, mayonnaise, Worcestershire, lemon juice, 1 tsp pepper, and 1 tsp salt. Refrigerate until serving.",
],
"family-sangria-ham": [
"Preheat oven to 325°F.",
"Pack the top of the ham with the whole pound of brown sugar. Some will fall off; leave it in the pan to sweeten the basting juices.",
"Use toothpicks to fasten the pineapple slices evenly over the outside of the ham. Reserve the juice.",
"Roast uncovered until the sugar begins to melt, about 20 minutes. Mix the pineapple juice and sangria and pour over the ham.",
"Return to the oven for 40 minutes. Baste, then tent with foil and return to the oven, basting every 20 minutes for the final hour. (About 15 minutes per pound: roughly 2 hours for an 8 lb ham.)",
],
"family-sausage-peppers-and-onions": [
"Microwave the peppers and onions 8 minutes until soft.",
"Meanwhile, brown the sliced sausage in a pan coated with cooking spray for 5 minutes. Remove from the pan.",
"Add 2 tsp olive oil and sauté the peppers and onions with oregano, basil, salt and pepper for 10 minutes. If the pan gets dry, add another tsp of olive oil and 2 tbsp water.",
"Add garlic and sauté 1–2 minutes. Return the sausage to the pan and mix together.",
"Add balsamic vinegar and cook 2 minutes. Serve with rolls or bread.",
],
"family-shrimp-scampi": [
"Cook linguine in salted boiling water until nearly tender, 6 to 8 minutes. Drain.",
"Melt 2 tbsp butter with 2 tbsp olive oil in a large skillet over medium heat. Cook shallots and garlic until shallots are translucent, 3 to 4 minutes.",
"Season shrimp with kosher salt and pepper; add to the skillet and cook until pink, 2 to 3 minutes. Remove and keep warm.",
"Pour wine, chili oil, and lemon juice into the skillet and bring to a boil, scraping up the browned bits.",
"Melt the remaining 2 tbsp butter in the skillet, stir in 2 tbsp olive oil, and bring to a simmer.",
"Toss linguine, shrimp, and parsley in the butter sauce; season with salt and pepper. Drizzle with 1 tsp olive oil to serve.",
],
"family-slow-cooker-chicken-and-dumplings": [
"Place chicken, vegetables, condensed soup, diced onion, and butter in a slow cooker. Add enough water to cover (about 2 cups). Stir well.",
"Cover and cook on High 5 to 6 hours.",
"About 30 minutes before serving, tear the biscuit dough into pieces and place in the slow cooker. Cook until the dough is no longer raw in the center.",
],
"family-slow-cooker-creamy-chicken-taco-soup": [
"Spray a slow cooker with cooking spray. Add onions, bell pepper, and 1 teaspoon salt.",
"Stir in chicken broth, diced tomatoes and chiles, condensed soup, and green chiles.",
"Combine oil, 1 tbsp taco seasoning, remaining 1 teaspoon salt, cumin, and black pepper. Add chicken and toss to coat; transfer to the slow cooker.",
"Cook on low 3 hours, until the chicken is no longer pink in the middle. Remove and shred.",
"Stir cream cheese into the slow cooker until melted. Return the chicken and cook until warmed through, 20 to 30 minutes more.",
],
"family-slow-cooker-honey-garlic-chicken": [
"Place chicken in the bottom of the slow cooker.",
"Whisk together soy sauce, honey, hoisin, vinegar, sesame oil, ginger, garlic, sriracha and the white parts of the scallions. Pour over the chicken and cook 3 hours on low.",
"Remove the chicken and let it rest a couple of minutes. Shred with two forks or slice.",
"Transfer the liquid to a small saucepan. Whisk 2 tbsp water with 1 tbsp cornstarch and slowly add it to the sauce. Cook on high until bubbly and thickened, whisking constantly.",
"Pour the sauce back into the slow cooker with the chicken and toss to coat.",
],
"family-slow-cooker-pot-roast": [
"Place the roast on the bottom of the slow cooker.",
"Layer cubed potatoes, halved baby carrots, onion pieces, and green beans over the meat. Season with salt and pepper.",
"Cover with water to just above the top of the vegetables. Leave room at the top, since it will bubble as it cooks.",
"Cook 6–8 hours on high until the meat is cooked through.",
],
"family-slow-cooker-tuscan-chicken": [
"Place chicken thighs in the slow cooker (sprayed with cooking spray) and sprinkle with rosemary, oregano, parsley, salt, and pepper.",
"Layer sun-dried tomatoes and halved olives over the chicken.",
"Heat olive oil in a saucepan over medium heat. Cook the sliced onion 3 to 4 minutes, add garlic and cook until fragrant, about 1 minute. Spread the onions over the tomatoes, leaving the oil in the pan.",
"Return the pan to medium heat and add the cream, stirring into the oil until it begins to bubble. Pour evenly over the onions.",
"Cover and cook on High 4 hours. Remove the chicken to a platter and keep warm.",
"Add torn spinach to the sauce and cook until wilted, 3 to 5 minutes. Stir in cornstarch [mixed with a little water] and cook, stirring constantly, until bubbly and thickened. Spoon over the chicken.",
],
"family-spicy-thai-basil-chicken": [
"Heat the chili oil in a skillet over medium-high heat, and cook the garlic and chili peppers until golden brown.",
"Mix in chicken and sugar, and season with garlic salt and pepper. Cook until the chicken is no longer pink, but not done.",
"Stir in the oyster sauce.",
"Mix in mushrooms, bell peppers, and onions, and continue cooking until onions are tender and chicken juices run clear.",
"Remove from heat, mix in basil, and let sit 2 minutes before serving.",
],
"family-zucchini-and-ground-beef-skillet": [
"Heat a large skillet over medium-high heat. Cook beef and onion until the beef is browned and the onion translucent, 5 to 7 minutes. Drain grease.",
"Crush the stewed tomatoes into smaller pieces and pour into the skillet.",
"Stir in mushrooms, tomato sauce, olives, garlic, salt, and pepper. Simmer uncovered until the liquid reduces, 20 to 30 minutes.",
"Slice zucchini lengthwise, then crosswise into ½-inch slices. Add to the beef; cover and cook until firm yet tender, 5 to 7 minutes.",
"Stir in the cheddar; cover and cook until melted, 3 to 4 minutes.",
],
// --- Desserts --------------------------------------------------------------------------
"family-apple-crisp": [
"Preheat the oven to 350°F.",
"Place peeled, sliced apples in a 9×13 baking dish. Mix white sugar, 1 tbsp flour, and cinnamon; sprinkle over the apples. Pour 3 tbsp water evenly over the apples.",
"Combine oats, 1 cup flour, brown sugar, baking powder, and baking soda in a large bowl. Add melted butter and mix with a fork until crumbly; sprinkle evenly over the apples.",
"Bake until the top is golden brown and the apples are bubbling around the edges, about 45 minutes.",
],
"family-apple-strudel-muffins": [
"Preheat the oven to 375°F. Grease or line a 12-cup muffin tin.",
"Mix flour, baking powder, baking soda, and salt in a medium bowl.",
"Beat sugar, butter, and eggs in a large bowl until smooth. Mix in vanilla. Stir in apples, then gradually blend in the flour mixture.",
"Spoon batter into the cups, filling each ⅔ full.",
"Topping: mix brown sugar, 1 tbsp flour, and cinnamon. Cut in 1 tbsp butter until it resembles coarse crumbs. Sprinkle over each muffin.",
"Bake until a toothpick comes out clean, about 20 minutes. Let sit 5 minutes before moving to a wire rack.",
],
"family-chocolate-chip-pumpkin-bread": [
"Preheat the oven to 350°F. Grease and flour two 9×5 loaf pans.",
"Beat sugar, pumpkin, eggs, oil, and ⅔ cup water in a large bowl with an electric mixer until smooth.",
"Blend in flour, cinnamon, nutmeg, baking soda, and salt. Fold in chocolate chips.",
"Divide batter between the pans (½ to ¾ full). Bake until a knife in the center comes out clean, about 1 hour.",
"Cool on wire racks before removing from the pans.",
],
"family-christmas-crack": [
"Preheat the oven to 400°F. Line a rimmed baking sheet with saltines in a single layer.",
"Combine butter and brown sugar in a saucepan. Bring to a boil and boil 3 minutes.",
"Immediately pour over the saltines and spread to cover completely. Bake 5 to 6 minutes.",
"Remove from the oven and sprinkle chocolate chips over top. Let sit 5 minutes, then spread the softened chocolate evenly. Sprinkle with pecans.",
"Cool completely, then break into pieces. Enjoy!",
],
"family-cowboy-cookies": [
"Preheat oven to 350°F. Grease baking sheets.",
"Mix together flour, baking powder, baking soda, and salt; set aside.",
"In a large bowl, cream the butter, sugar, and brown sugar until light and fluffy. Beat in the eggs one at a time, then the vanilla.",
"Add the dry ingredients and stir. Stir in the oats and chocolate chips.",
"Drop dough in small balls onto the sheet and bake 8–10 minutes. Cool 5 minutes before moving to a rack.",
],
"family-cream-cheese-frosting": [
"Let the cream cheese sit out until softened.",
"Beat cream cheese, butter, and vanilla in a mixer until smooth.",
"Slowly add the powdered sugar and beat until fluffy.",
],
"family-ginger-kringles": [
"Preheat oven to 350°F.",
"Mix oil and 1 cup sugar in a mixing bowl. Beat in the egg, then the molasses.",
"Mix the dry ingredients in a bowl, then slowly add to the wet mixture.",
"Form dough into small balls and roll in extra sugar to coat.",
"Bake on a cookie sheet 9 minutes. Sprinkle a fingerful of sugar on the warm cookies, then let sit 5 minutes before moving to a cooling rack.",
],
"family-million-dollar-fudge": [
"Butter two 9×9 pans and set aside.",
"Place chocolate chips, German (or dark) chocolate, marshmallow crème, and nuts in a large mixing bowl.",
"In a large saucepot, combine sugar, salt, butter, and evaporated milk. Stir over low heat until the sugar dissolves.",
"Bring to a boil and cook 6 minutes.",
"Pour the boiling syrup over the chocolate mixture and beat until all the chocolate is melted. Pour into the pans and cool several hours before cutting into squares.",
],
"family-moms-banana-bread": [
"Heat oven to 350°F. Grease the bottoms of loaf pans (2 small or 1 large).",
"Mix sugar and butter in a large bowl with a mixer. Add eggs and blend well.",
"Add mashed bananas, buttermilk, and vanilla and beat until the batter is nearly smooth.",
"Stir in flour, baking soda, and salt until just moistened.",
"Bake 1 hr 15 min for a large loaf (1 hr for small) until a toothpick in the center comes out clean. Cool 5 minutes, cut along the edges, and remove from the pan.",
],
"family-ooey-gooey-bars": [
"Heat oven to 350°F. Grease a 9×13 pan. Melt the butter.",
"Mix oats, flour, brown sugar, salt, baking soda, and butter until crumbly. Reserve 1½ cups for the topping; press the rest into the pan as a crust.",
"Bake 12 minutes. Meanwhile, unwrap the caramels and melt them with the cream in a saucepan.",
"Sprinkle both kinds of chocolate chips over the baked crust, then top with the caramel mixture [and the reserved crumble].",
"Bake another 10–12 minutes until the chips are melted. Cool before serving.",
],
"family-peppermint-chocolate-thumbprint-cookies": [
"Preheat oven to 350°F.",
"Cream butter and both sugars with a mixer. Add eggs one at a time, mixing just until incorporated. Mix in mint extract.",
"Combine flour, cocoa, salt, baking powder and baking soda. Add to the butter mixture ½ cup at a time, blending just until combined. Refrigerate 10 minutes.",
"Butter your hands and roll dough into 1-inch balls. Place on a parchment-lined sheet.",
"Bake 10 minutes. Cool 5 minutes only, then press a peppermint kiss into each cookie. Move to racks carefully without touching the kiss.",
],
"family-soft-sugar-cookies": [
"Combine flour, baking powder, and salt. Set aside.",
"Cream the butter and sugar until light and fluffy. Beat in the eggs one at a time, then 2 tsp vanilla. Gradually blend in the dry ingredients.",
"Cover the dough and chill 2 hours.",
"Preheat oven to 350°F. Roll small portions of dough to ¼ inch on a floured surface and cut out shapes.",
"Bake 6 to 8 minutes until the edges are barely brown. Cool on wire racks.",
"Frosting: cream powdered sugar and shortening until smooth. Gradually mix in milk and 1 tsp vanilla until smooth and stiff, about 5 minutes. Color with food coloring if desired, and ice the cooled cookies.",
],
};

View file

@ -0,0 +1,113 @@
// Steps for the original recipes (recipes/*.ts), keyed by recipe id.
export const CORE_STEPS: Record<string, string[]> = {
// --- Breakfasts -----------------------------------------------------------
"overnight-oats": ["Stir oats, milk, yogurt and honey together in a bowl or jars.", "Fold in the frozen berries.", "Cover and refrigerate overnight (at least 6 hours).", "Stir and eat cold, or microwave 1 minute."],
"breakfast-burritos": ["Dice potatoes and fry in oil over medium heat until golden, 12–15 min.", "Push potatoes aside, scramble the eggs in the same pan.", "Fill tortillas with potatoes, eggs, cheese and salsa; roll tightly.", "Eat now, or wrap in foil and refrigerate; reheat 1–2 min in the microwave."],
"pancakes": ["Whisk flour, baking powder and brown sugar in a bowl.", "Whisk in milk, eggs and 2 tbsp melted butter until just combined (lumps are fine).", "Cook ¼-cup scoops on a buttered pan over medium heat; flip when bubbles form.", "Serve with syrup."],
"pb-banana-oatmeal": ["Combine oats and milk in a large microwave-safe bowl.", "Microwave 2–3 minutes, stirring halfway.", "Stir in peanut butter and honey.", "Top with sliced banana."],
"air-fryer-potato-hash": ["Cube potatoes; dice onion and pepper.", "Toss with oil, paprika and salt; air fry at 400°F for 18–20 min, shaking twice.", "Meanwhile fry the eggs in a skillet.", "Serve eggs over the hash."],
"egg-muffin-cups": ["Heat oven to 375°F and grease a 12-cup muffin tin.", "Cook bacon until crisp; chop. Chop spinach.", "Whisk eggs, divide bacon, spinach and cheese among cups, pour eggs over.", "Bake 20–22 min until set. Cool, then refrigerate up to 4 days."],
"microwave-scramble": ["Whisk 2 eggs per mug with a splash of water.", "Stir in a handful of chopped spinach and cheese.", "Microwave 45 sec, stir, then 30–45 sec more until set.", "Top with salsa."],
"shakshuka": ["Sauté diced onion and pepper in a skillet until soft, 6 min.", "Add cumin and paprika, then crushed tomatoes; simmer 10 min.", "Make wells and crack in the eggs; cover and cook 5–7 min until whites set.", "Top with feta; serve with warm pita."],
"biscuits-and-gravy": ["Heat oven to 425°F. Cut 4 tbsp butter into flour and baking powder; stir in 1 cup milk.", "Drop 8 mounds on a sheet; bake 12–15 min.", "Brown sausage in a skillet, sprinkle 3 tbsp flour over and stir 1 min.", "Slowly stir in remaining milk; simmer until thick, season with lots of pepper. Spoon over split biscuits."],
"sweet-potato-hash": ["Cube sweet potatoes, toss with oil; air fry at 400°F for 15 min.", "Brown sausage with diced onion and pepper in a skillet.", "Add the sweet potatoes to the skillet and toss.", "Fry eggs and serve on top."],
"huevos-rancheros": ["Warm black beans in a small pot; mash lightly.", "Warm tortillas in a dry skillet, then fry eggs in the same pan.", "Layer tortilla, beans, egg, salsa and cheese.", "Top with chopped cilantro."],
"plantains-and-eggs": ["Slice very ripe plantains on the diagonal.", "Fry in oil over medium heat 2–3 min per side until caramelized.", "Warm beans; fry or scramble the eggs.", "Serve together with salsa."],
"chicken-congee": ["Rinse rice; add to slow cooker with broth, ginger and whole chicken thighs.", "Cook on low 6–8 hours (overnight) until thick and creamy.", "Shred the chicken back into the pot; stir well.", "Season with soy sauce; top with sliced green onions and a drizzle of sesame oil."],
"tamago-rice-bowl": ["Cook rice.", "Whisk eggs with soy sauce and brown sugar.", "Cook thin layers in an oiled pan, rolling each up as it sets.", "Slice the omelet and serve over rice with wilted spinach and green onions."],
"gyeran-bap": ["Cook rice.", "Fry eggs in oil until edges are crispy.", "Top hot rice with an egg, soy sauce and sesame oil.", "Add green onions and kimchi; mix and eat."],
"berry-banana-smoothie": ["Add milk and yogurt to the blender first.", "Add banana, frozen berries and honey.", "Blend until smooth, adding milk if too thick."],
"tropical-smoothie": ["Add orange juice, yogurt and the canned pineapple (with juice) to the blender.", "Add frozen mango and banana.", "Blend until smooth."],
"pb-oat-smoothie": ["Blend oats alone first to a powder.", "Add milk, bananas, peanut butter, honey and cinnamon.", "Blend until smooth; add ice for a thicker shake."],
"green-smoothie": ["Blend almond milk and spinach until no leaves remain.", "Add banana, frozen mango and yogurt.", "Blend until smooth."],
"chocolate-pb-smoothie": ["Add almond milk, cocoa, peanut butter and honey to the blender.", "Add bananas (frozen if you have them).", "Blend until smooth."],
"mango-lassi": ["Add yogurt, milk and honey to the blender.", "Add frozen mango and cinnamon.", "Blend until smooth and frothy; serve cold."],
"berry-smoothie-bowls": ["Toast oats in a dry pan 3–4 min until fragrant; set aside.", "Blend frozen berries, 1 banana, yogurt and a little almond milk until thick.", "Spoon into bowls; top with sliced banana, toasted oats and honey."],
"blender-oat-pancakes": ["Blend oats to flour.", "Add bananas, eggs, milk, baking powder and cinnamon; blend until smooth. Rest 5 min.", "Pour onto a greased pan over medium heat; flip when bubbly.", "Serve with syrup."],
"blender-banana-muffins": ["Heat oven to 350°F; grease a muffin tin.", "Blend all ingredients until smooth.", "Fill cups ¾ full; bake 18–20 min until a toothpick comes out clean.", "Cool before storing."],
"rice-cooker-oatmeal": ["Add oats, milk, cinnamon and brown sugar to the rice cooker.", "Run the porridge setting (or regular cook, stirring once).", "Stir in sliced bananas and serve."],
// --- Mains: Americas ---------------------------------------------------------
"pulled-pork": ["Slice onion into the slow cooker; set pork on top.", "Mix BBQ sauce and brown sugar; pour over pork.", "Cook on low 8–10 hours until it shreds easily.", "Shred, toss with the juices, and serve on bread with slaw."],
"air-fryer-chicken-potatoes": ["Cut potatoes into chunks; toss with half the oil, salt and pepper.", "Rub chicken with remaining oil, garlic, paprika, salt and pepper.", "Air fry potatoes at 400°F for 10 min, add chicken, cook 15–18 min more.", "Check chicken reaches 165°F, then serve."],
"slow-cooker-chili": ["Brown the beef with diced onion; drain.", "Add to slow cooker with drained beans, crushed tomatoes, chili powder and cumin.", "Cook on low 6–8 hours or high 3–4.", "Top with cheddar and sour cream."],
"mac-and-cheese": ["Boil macaroni; add broccoli for the last 3 minutes. Drain.", "Melt butter in the pot, whisk in flour, cook 1 min.", "Whisk in milk; simmer until thick, then stir in cheddar until smooth.", "Fold in pasta and broccoli."],
"sausage-potato-skillet": ["Dice potatoes; cook in oil, covered, over medium heat 10 min.", "Add sliced sausage, onion and pepper; uncover and cook 10 min until browned.", "Season with paprika, salt and pepper."],
"loaded-baked-potatoes": ["Prick potatoes; microwave 8–12 min, turning halfway, until soft.", "Microwave bacon between paper towels until crisp; crumble.", "Steam broccoli in the microwave.", "Split potatoes; top with cheese, broccoli, bacon, sour cream and green onions."],
"sheet-pan-sausage": ["Heat oven to 425°F.", "Cut potatoes, sausage and peppers into bite-size pieces.", "Toss with broccoli, oil and Italian seasoning on a sheet pan.", "Roast 25–30 min, stirring once."],
"beef-tacos": ["Brown beef in a skillet; drain.", "Add taco seasoning and ⅔ cup water; simmer 5 min.", "Warm tortillas.", "Fill with beef, cheese, slaw and salsa."],
"chicken-tortilla-soup": ["Add chicken, beans, tomatoes, broth, diced onion and taco seasoning to the slow cooker.", "Cook on low 6 hours; shred chicken in the pot.", "Slice tortillas into strips and toast in a dry pan until crisp.", "Serve topped with strips, cheese and lime."],
"black-bean-quesadillas": ["Mash beans with cumin; dice pepper.", "Spread beans on half of each tortilla; add pepper and cheese; fold.", "Cook in a skillet over medium 2–3 min per side until crisp.", "Cut into wedges; serve with salsa."],
"chicken-burrito-bowls": ["Cook rice.", "Season chicken with taco seasoning; cook in a skillet 6–7 min per side. Slice.", "Warm beans.", "Build bowls: rice, beans, chicken, salsa, cheese and a squeeze of lime."],
"red-beans-and-rice": ["Brown sliced sausage in a skillet.", "Add to slow cooker with beans, diced onion, pepper, celery, Cajun seasoning and broth.", "Cook on low 6–8 hours; mash some beans to thicken.", "Serve over cooked rice."],
"jambalaya": ["Brown sliced sausage and diced chicken in a large pot.", "Add onion, pepper and celery; cook 5 min.", "Stir in rice, tomatoes, broth and Cajun seasoning; bring to a boil.", "Cover and simmer on low 20–25 min until rice is tender."],
"shrimp-and-grits": ["Bring broth and milk to a boil; whisk in grits. Simmer 5 min, then stir in cheese and butter.", "Cook chopped bacon until crisp; remove.", "Toss shrimp with Cajun seasoning; cook in bacon fat 2 min per side.", "Serve shrimp over grits with bacon and green onions."],
"air-fryer-tenders": ["Slice chicken into strips.", "Dip in beaten egg, then breadcrumbs mixed with paprika and salt.", "Air fry at 400°F for 10–12 min, flipping once.", "Toss slaw with yogurt, lemon juice, salt and pepper; serve alongside."],
"jerk-chicken-rice-peas": ["Rub chicken with jerk seasoning and lime juice; roast at 425°F 30–35 min.", "In a pot, combine rice, coconut milk, drained beans and 1¼ cups water.", "Bring to a boil, cover and simmer 18–20 min.", "Serve chicken over rice; top with green onions."],
"pineapple-pork": ["Slice onion into the slow cooker; add pork.", "Mix pineapple (with juice), jerk seasoning, brown sugar and soy; pour over.", "Cook on low 8 hours; shred.", "Serve over rice with warmed black beans."],
"sweet-potato-bean-stew": ["Sauté diced onion until soft.", "Add cubed sweet potatoes, curry powder and jerk seasoning; stir 1 min.", "Add coconut milk, tomatoes and beans; simmer 20 min until potatoes are tender.", "Stir in spinach until wilted; serve over rice."],
"chicken-enchiladas": ["Simmer chicken in salted water 15 min; shred. Heat oven to 375°F.", "Mix chicken with sour cream, diced onion and half the cheese.", "Roll into tortillas; place seam-down in a dish.", "Pour enchilada sauce over, top with remaining cheese; bake 20 min."],
"microwave-sweet-potato-bowls": ["Prick sweet potatoes; microwave 8–10 min until soft.", "Microwave beans and corn with cumin 2 min.", "Split potatoes; fill with beans and corn.", "Top with salsa, yogurt and lime."],
"rice-cooker-burrito-rice": ["Rinse rice; add to rice cooker.", "Add drained beans, salsa, taco seasoning, corn and water to the 2-cup line.", "Run the white rice cycle.", "Fluff, stir in cheese, top with cilantro."],
"rice-cooker-sausage-rice": ["Rinse rice; add to rice cooker with broth.", "Add sliced sausage, frozen veg, diced onion and Cajun seasoning on top.", "Run the white rice cycle; rest 10 min.", "Fluff and serve."],
"tomato-soup-grilled-cheese": ["Sauté diced onion in 2 tbsp butter until soft.", "Add crushed tomatoes and basil; simmer 15 min.", "Blend until smooth (vent the lid), then stir in cream.", "Butter bread, fill with cheddar and griddle until golden. Serve with soup."],
"blender-black-bean-soup": ["Sauté diced onion with cumin 5 min.", "Add beans (with liquid), salsa and 1 cup water; simmer 10 min.", "Blend half the soup, then stir it back in.", "Top with sour cream, lime and cilantro."],
// --- Mains: Mediterranean --------------------------------------------------
"spaghetti-meat-sauce": ["Boil spaghetti.", "Brown beef with diced onion and garlic; drain.", "Add marinara; simmer 10 min.", "Serve over pasta with parmesan."],
"baked-ziti": ["Heat oven to 375°F; boil penne 2 min short of done.", "Brown turkey with Italian seasoning; add marinara and crushed tomatoes.", "Mix pasta into sauce; layer in a dish with mozzarella.", "Top with remaining mozzarella and parmesan; bake 20–25 min."],
"chicken-alfredo": ["Boil penne; add broccoli for the last 3 min. Drain.", "Cook bite-size chicken in butter until done; add garlic.", "Add cream; simmer 3 min, then stir in parmesan.", "Toss with pasta and broccoli."],
"air-fryer-meatballs": ["Mix turkey, breadcrumbs, egg, parmesan and Italian seasoning; roll 16 balls.", "Air fry at 380°F for 10–12 min.", "Boil spaghetti; warm marinara.", "Toss meatballs in sauce and serve over pasta."],
"med-chicken-bowls": ["Heat oven to 425°F. Toss chicken and sliced peppers with oil, paprika, cumin, salt.", "Roast 20–22 min.", "Cook rice. Mix yogurt with garlic and lemon juice.", "Build bowls: rice, chicken and peppers, yogurt sauce."],
"chickpea-salad": ["Drain and rinse chickpeas.", "Chop spinach and pepper.", "Toss everything with lemon juice, olive oil, salt and pepper.", "Top with parmesan. Keeps 3 days."],
"slow-cooker-lentil-soup": ["Dice onion, carrots and celery.", "Add to slow cooker with rinsed lentils, tomatoes, broth and cumin.", "Cook on low 6–8 hours.", "Stir in lemon juice; season to taste."],
"chicken-shawarma-pitas": ["Heat oven to 425°F. Toss chicken with oil, cumin, paprika, cinnamon and salt.", "Roast 20–25 min; slice.", "Mix yogurt with garlic and lemon juice; dice cucumber and tomato.", "Fill warm pitas with chicken, veg and sauce."],
"air-fryer-falafel": ["Pulse drained chickpeas, onion, cilantro, garlic, cumin and flour until coarse.", "Form 16 small patties; brush with oil.", "Air fry at 375°F for 12–14 min, flipping once.", "Serve in pitas with yogurt, cucumber and tomato."],
"mujadara": ["Thinly slice onions; cook in oil over medium-low 25–30 min until deep brown.", "Meanwhile simmer lentils in water 10 min; add rice, cumin and more water; cover and cook 18 min.", "Fold half the onions into the rice and lentils.", "Top with remaining onions; serve with yogurt and cucumber."],
"greek-salad-pitas": ["Drain chickpeas; dice cucumber and tomatoes.", "Toss with feta, lemon juice, olive oil, salt and pepper.", "Stuff into pitas."],
"pasta-e-fagioli": ["Sauté diced onion, carrots and celery 5 min.", "Add tomatoes, beans, broth and Italian seasoning; simmer 15 min.", "Add pasta; cook 8–10 min until tender.", "Serve with parmesan."],
"gazpacho": ["Roughly chop tomatoes, cucumber, pepper and onion.", "Blend with garlic, olive oil, lemon juice and salt until smooth.", "Chill at least 1 hour.", "Serve with chickpeas tossed in oil and warm pita."],
"budget-pesto-pasta": ["Boil spaghetti; save ½ cup pasta water.", "Blend basil, spinach, parmesan, peanuts, garlic, lemon juice and olive oil.", "Toss pasta with pesto, loosening with pasta water."],
// --- Mains: Asia -------------------------------------------------------------
"chicken-fried-rice": ["Cook rice ideally a day ahead; chill.", "Stir-fry diced chicken in oil until cooked; push aside.", "Scramble eggs; add frozen veg and garlic.", "Add rice and soy sauce; stir-fry until hot. Top with green onions."],
"honey-garlic-stir-fry": ["Cook rice.", "Toss chicken pieces in cornstarch; brown in oil.", "Add broccoli and sliced carrots; stir-fry 4 min.", "Add soy, honey and garlic; toss until glossy. Serve over rice."],
"peanut-noodles-tofu": ["Press and cube tofu; pan-fry until crisp.", "Boil spaghetti.", "Whisk peanut butter, soy, honey, lime juice and hot pasta water into a sauce.", "Toss noodles, sauce, shredded carrots and tofu; top with green onions."],
"chicken-curry": ["Cook rice.", "Sauté diced onion with garlic and curry powder 3 min.", "Add bite-size chicken; cook 5 min.", "Add tomatoes and coconut milk; simmer 15 min."],
"red-lentil-dal": ["Sauté diced onion with garlic, curry powder and cumin.", "Add rinsed lentils, tomatoes, coconut milk and 2 cups water.", "Simmer 20 min until lentils break down; stir in spinach.", "Serve over rice."],
"chickpea-spinach-curry": ["Cook rice.", "Sauté diced onion and garlic; add curry powder.", "Add drained chickpeas and coconut milk; simmer 10 min.", "Stir in spinach until wilted."],
"beef-and-broccoli": ["Cook rice.", "Brown beef; drain.", "Add broccoli; cook 4 min.", "Whisk soy, brown sugar, ginger, garlic, cornstarch and ¼ cup water; pour in and simmer until thick. Finish with sesame oil."],
"kung-pao-chicken": ["Cook rice.", "Whisk soy, vinegar, brown sugar, cornstarch and sriracha into a sauce.", "Stir-fry diced chicken until cooked; add peppers and garlic 2 min.", "Add sauce and peanuts; toss until glossy. Top with green onions."],
"pork-lo-mein": ["Boil spaghetti.", "Brown pork; add slaw mix and shredded carrots, cook 3 min.", "Add soy, sesame oil, brown sugar and ginger.", "Toss in noodles; top with green onions."],
"teriyaki-chicken-bowls": ["Cook rice.", "Air fry chicken at 400°F 15–18 min.", "Steam broccoli.", "Slice chicken, toss in warmed teriyaki sauce; serve over rice with broccoli."],
"japanese-curry": ["Brown chicken pieces with diced onion.", "Add cubed potatoes, carrots and broth; simmer 15 min.", "In a small pan, melt butter, whisk in flour and curry powder 2 min to make a roux.", "Stir roux, honey and soy into the pot; simmer until thick. Serve over rice."],
"oyakodon": ["Cook rice.", "Simmer sliced onion in broth, soy and brown sugar 5 min.", "Add bite-size chicken; simmer 6–8 min.", "Pour beaten eggs over, cover 1–2 min until just set. Slide over rice."],
"bulgogi-beef-bowls": ["Cook rice.", "Brown beef; drain.", "Add soy, brown sugar, sesame oil, ginger and garlic; simmer 2 min.", "Serve over rice with sliced cucumber and green onions."],
"bibimbap": ["Cook rice.", "Sauté spinach and shredded carrots separately; season with soy and sesame oil.", "Brown beef with soy; fry eggs.", "Top rice with veg, beef and egg; add gochujang and mix."],
"kimchi-fried-rice": ["Fry diced sausage until browned.", "Add chopped kimchi and gochujang; cook 3 min.", "Add cold rice; stir-fry until crisp in spots. Drizzle sesame oil.", "Top with fried eggs and green onions."],
"gochujang-pork": ["Mix gochujang, soy, brown sugar, ginger and garlic.", "Coat pork and cook in the slow cooker on low 8 hours.", "Shred in the sauce.", "Serve over rice with slaw."],
"kimchi-ramen": ["Microwave each ramen with 2 cups water in a large bowl 3 min.", "Stir in seasoning, frozen veg and kimchi.", "Crack in an egg; microwave 1 min more.", "Top with green onions and sesame oil."],
"thai-red-curry": ["Cook rice.", "Fry curry paste in a little coconut milk 1 min.", "Add chicken; cook 5 min. Add remaining coconut milk, pepper and carrots; simmer 10 min.", "Season with fish sauce, brown sugar and lime; stir in basil."],
"pad-krapow": ["Cook rice.", "Stir-fry garlic and sliced jalapeños 30 sec; add pork and cook through.", "Add fish sauce, soy and brown sugar; toss in basil until wilted.", "Serve over rice with crispy fried eggs."],
"thai-peanut-sheet-pan": ["Heat oven to 425°F. Whisk peanut butter, soy, lime juice, honey and sriracha.", "Toss chicken in half the sauce; cube sweet potatoes.", "Roast chicken, sweet potatoes and broccoli 25 min.", "Drizzle with remaining sauce."],
"slow-cooker-butter-chicken": ["Add chicken, diced onion, tomato paste, curry powder, ginger, garlic and ½ cup water to slow cooker.", "Cook on low 6 hours.", "Stir in cream and butter; shred chicken slightly.", "Serve over rice."],
"rice-cooker-chicken-rice": ["Rinse rice; add to cooker with broth, ginger and garlic.", "Lay chicken thighs on top; run the white rice cycle.", "Rest 10 min; slice chicken.", "Serve with cucumber, a soy-sriracha dip and green onions."],
"takikomi-gohan": ["Rinse rice; add to cooker with broth, soy and brown sugar.", "Scatter diced chicken and shredded carrots on top (don't stir).", "Run the white rice cycle.", "Fluff, mixing everything; top with green onions."],
"rice-cooker-kitchari": ["Rinse rice and lentils together.", "Add to cooker with tomatoes, curry powder, cumin, ginger and 4 cups water.", "Run the porridge or white rice cycle.", "Stir in spinach and butter."],
// --- Seafood --------------------------------------------------------------
"tuna-melts": ["Mix drained tuna, mayo and diced celery.", "Spread on bread, top with cheddar, close sandwiches.", "Butter outsides; griddle over medium 3 min per side."],
"tuna-chickpea-pitas": ["Drain tuna and chickpeas.", "Dice cucumber and tomatoes.", "Toss everything with lemon juice, olive oil, salt and pepper.", "Stuff into pitas."],
"baja-fish-tacos": ["Season fish with taco seasoning; sear in oil 3–4 min per side, then flake.", "Mix sour cream with lime juice.", "Warm tortillas.", "Fill with fish, slaw, lime crema and salsa."],
"old-bay-tilapia": ["Cook rice; steam broccoli.", "Brush fish with melted butter; sprinkle Old Bay.", "Air fry at 400°F for 8–10 min until flaky.", "Squeeze lemon over; serve with rice and broccoli."],
"jerk-tilapia-mango": ["Cook rice. Heat oven to 400°F.", "Rub fish with jerk seasoning; bake 12–15 min.", "Dice thawed mango, onion and cilantro; toss with lime juice.", "Serve fish over rice topped with salsa."],
"coconut-fish-curry": ["Cook rice.", "Sauté diced onion with ginger and curry powder 3 min.", "Add tomatoes and coconut milk; simmer 10 min.", "Nestle in fish pieces; simmer 6–8 min until flaky. Serve over rice."],
"linguine-clams": ["Boil spaghetti.", "Warm oil and butter; cook garlic 1 min.", "Add clams with their juice; simmer 5 min, add lemon juice.", "Toss with pasta and parmesan."],
"california-sushi-bowls": ["Cook rice; stir in rice vinegar and a pinch of sugar and salt.", "Shred crab; slice cucumber and avocado.", "Mix mayo and sriracha.", "Build bowls; top with crumbled seaweed, spicy mayo and soy."],
"shrimp-fried-rice": ["Use cold, day-old rice if possible.", "Cook shrimp in oil 2 min; remove.", "Scramble eggs; add frozen veg and garlic.", "Add rice, soy and shrimp; stir-fry until hot. Top with green onions."],
"thai-shrimp-curry": ["Cook rice.", "Fry curry paste in a splash of coconut milk 1 min.", "Add remaining coconut milk and sliced pepper; simmer 5 min.", "Add shrimp; cook 3 min. Finish with fish sauce, lime and basil."],
"cajun-shrimp-boil": ["Heat oven to 425°F. Cube potatoes; roast 15 min.", "Add sliced sausage and corn; roast 10 min.", "Add shrimp; drizzle with butter melted with Old Bay; roast 6–8 min.", "Squeeze lemon over everything."],
"lemon-garlic-salmon": ["Heat oven to 425°F. Cube potatoes, toss with oil; roast 15 min.", "Add broccoli and salmon to the pan.", "Brush salmon with oil, garlic, paprika and lemon juice.", "Roast 12–15 min until salmon flakes."],
"rice-cooker-teriyaki-salmon": ["Rinse rice; add to cooker with water to the line.", "Lay salmon and broccoli on top (or in the steamer basket).", "Run the white rice cycle.", "Drizzle teriyaki over the salmon; top with green onions."],
};

View file

@ -0,0 +1,110 @@
// Steps for recipes/extra/americas-north.ts and americas-south.ts, keyed by recipe id.
export const EXTRA_AMERICAS_STEPS: Record<string, string[]> = {
// --- American -------------------------------------------------------------
"smash-burgers": ["Divide beef into 8 balls.", "Heat a skillet very hot; smash each ball flat with a spatula, season, cook 2 min.", "Flip, top with cheese and a second patty if you like, cook 1 min.", "Serve on buns with diced onion, ketchup and mustard."],
"turkey-burgers-and-oven-fries": ["Bake fries per the bag at 425°F.", "Mix turkey with garlic powder, salt and pepper; form 4 patties.", "Cook in an oiled skillet 5–6 min per side to 165°F.", "Serve on buns with lettuce and mayo."],
"sloppy-joes": ["Brown beef with diced onion; drain.", "Stir in tomato sauce, ketchup, brown sugar and Worcestershire.", "Simmer 10 min until thick.", "Spoon onto buns."],
"chili-dogs": ["Brown beef with half the onion; add tomato sauce and chili powder; simmer 10 min.", "Simmer or pan-fry the hot dogs.", "Bun the dogs; top with chili, cheese and remaining diced onion."],
"philly-cheesesteak-hoagies": ["Freeze beef 20 min, then slice paper-thin.", "Sauté sliced pepper and onion until soft; push aside.", "Sear beef 2–3 min with Worcestershire, salt and pepper; mix with the veg.", "Top with mozzarella to melt; pile into rolls."],
"meatball-subs": ["Heat oven to 400°F.", "Simmer meatballs in marinara 15 min.", "Fill rolls with meatballs and sauce; top with mozzarella.", "Bake 5–7 min until the cheese melts."],
"turkey-club-wraps": ["Spread mayo on tortillas.", "Layer turkey, lettuce, sliced tomato and cheese.", "Roll tightly and cut in half."],
"buffalo-chicken-wraps": ["Shred chicken; toss with buffalo sauce.", "Chop lettuce and celery.", "Fill tortillas with chicken, veg and a drizzle of ranch; roll."],
"chicken-salad-crescent-sandwiches": ["Bake crescent rolls per the can; cool slightly and split.", "Shred chicken; dice celery and apple.", "Mix with mayo, salt and pepper.", "Fill the rolls."],
"grilled-ham-and-cheese": ["Butter one side of each bread slice.", "Fill with ham and cheddar, butter-side out.", "Cook over medium-low 3–4 min per side until golden and melty."],
"bbq-chicken-sliders": ["Shred chicken; mix with BBQ sauce and microwave 1–2 min until hot.", "Toss slaw mix with mayo, salt and pepper.", "Pile chicken and slaw onto buns."],
"air-fryer-hot-dogs-and-fries": ["Air fry fries at 400°F 12 min, shaking once.", "Add hot dogs; cook 4–5 min more.", "Toast buns in the basket 1 min; serve with ketchup and mustard."],
"chicken-pot-pie": ["Heat oven to 375°F.", "Mix chicken, frozen veg, soups and milk in a 9×13 dish.", "Top with biscuits.", "Bake 25–30 min until biscuits are golden and filling bubbles."],
"tuna-noodle-casserole": ["Heat oven to 375°F; boil noodles 2 min short.", "Mix noodles, tuna, soups, peas and half the cheese.", "Spread in a dish; top with remaining cheese and panko.", "Bake 20–25 min."],
"hash-brown-hotdish": ["Heat oven to 375°F.", "Brown beef with diced onion; drain. Stir in soup and green beans.", "Spread in a 9×13 dish; top with cheese, then hash browns.", "Bake 35–40 min until the top is crisp."],
"chicken-and-rice-casserole": ["Heat oven to 375°F.", "Mix uncooked rice, soups, broth and broccoli in a 9×13 dish.", "Lay seasoned chicken on top; cover with foil.", "Bake 50 min, uncover, add cheese, bake 10 min more."],
"broccoli-cheddar-chicken-bake": ["Heat oven to 400°F.", "Cube chicken; mix with broccoli and soup in a dish.", "Top with cheese and panko.", "Bake 30 min until chicken is cooked through."],
"baked-mac-and-cheese-with-ham": ["Heat oven to 375°F; boil macaroni 2 min short.", "Melt butter, whisk in flour, then milk; simmer until thick. Stir in 2½ cups cheese.", "Fold in pasta and ham; spread in a dish.", "Top with remaining cheese and panko; bake 20–25 min."],
"meatloaf-and-mashed-potatoes": ["Heat oven to 375°F. Mix beef, breadcrumbs, egg, diced onion and half the ketchup; shape a loaf.", "Spread remaining ketchup on top; bake 55–60 min.", "Boil cubed potatoes until tender; mash with butter and milk.", "Rest the meatloaf 10 min before slicing."],
"shepherds-pie": ["Boil cubed potatoes; mash with butter and milk.", "Brown beef; stir in tomato paste, Worcestershire, broth and frozen veg; simmer 5 min.", "Spread filling in a dish; top with mash.", "Bake at 400°F 20 min until golden."],
"pepperoni-pizza": ["Heat oven to 425°F; press dough onto a greased pan.", "Spread tomato sauce mixed with Italian seasoning.", "Top with mozzarella and pepperoni.", "Bake 12–15 min until crust is golden."],
"bbq-chicken-pizza": ["Heat oven to 425°F; press dough onto a pan.", "Spread BBQ sauce; top with shredded chicken, mozzarella and thin-sliced onion.", "Bake 12–15 min; scatter cilantro."],
"veggie-supreme-pizza": ["Heat oven to 425°F; press dough onto a pan.", "Spread tomato sauce; top with mozzarella, mushrooms, pepper and onion.", "Bake 13–16 min."],
"pigs-in-a-blanket-and-slaw": ["Heat oven to 375°F.", "Cut hot dogs in thirds; roll each in a strip of crescent dough.", "Bake 12–15 min until golden.", "Toss slaw with mayo, mustard, salt and pepper."],
"beef-stroganoff": ["Boil egg noodles.", "Brown beef with diced onion and mushrooms; sprinkle flour over, stir 1 min.", "Stir in broth; simmer 5 min until thick.", "Off the heat, stir in sour cream. Serve over noodles."],
"cheeseburger-pasta-skillet": ["Brown beef; drain.", "Add dry macaroni, broth, milk, ketchup and mustard; bring to a boil.", "Cover and simmer 10–12 min, stirring, until pasta is tender.", "Stir in cheese until melted."],
"chicken-noodle-soup": ["Sauté diced onion, carrots and celery 5 min.", "Add broth; simmer 10 min.", "Add noodles; cook 6–8 min.", "Stir in shredded chicken; season."],
"slow-cooker-beef-stew": ["Toss beef with flour, salt and pepper.", "Add to slow cooker with cubed potatoes, carrots, onion, tomato paste, broth and Worcestershire.", "Cook on low 8 hours.", "Stir and season before serving."],
"slow-cooker-mushroom-pork-chops": ["Cube potatoes and carrots into the slow cooker.", "Lay chops on top; slice onion over.", "Spoon soup over everything.", "Cook on low 6–7 hours."],
"pork-chops-and-applesauce-skillet": ["Season chops; sear in butter 4–5 min per side; set aside.", "Add sliced apples, brown sugar and cinnamon; cook 5 min until soft.", "Steam green beans.", "Serve chops with the apples and beans."],
"air-fryer-pork-chops-and-green-beans": ["Rub chops with oil, garlic powder, paprika and salt.", "Air fry at 400°F 12–14 min to 145°F, flipping once.", "Toss green beans with oil and salt; air fry 8 min.", "Rest chops 5 min before serving."],
"honey-mustard-drumsticks": ["Heat oven to 425°F.", "Whisk honey, mustard and garlic powder; toss with drumsticks.", "Roast with cubed potatoes on a sheet pan 40–45 min, turning once.", "Check chicken reaches 175°F."],
"air-fryer-buffalo-drumsticks": ["Pat drumsticks dry; salt.", "Air fry at 380°F 25–28 min, flipping halfway.", "Toss in buffalo sauce.", "Serve with ranch, celery and carrot sticks."],
"slow-cooker-salsa-chicken": ["Add chicken, salsa and taco seasoning to the slow cooker.", "Cook on low 6 hours or high 3.", "Shred in the sauce.", "Serve over rice (or in tortillas, bowls, salads)."],
"rotisserie-chicken-cobb-salad": ["Hard-boil eggs 10 min; cool and chop.", "Cook bacon; crumble.", "Chop lettuce, tomato and avocado; shred chicken.", "Arrange in rows over lettuce; drizzle with ranch."],
"microwave-chili-mac": ["In a large microwave bowl, cover pasta with water by 1 inch; microwave 10–12 min, stirring every 3.", "Drain; stir in beans, tomato sauce and chili powder.", "Microwave 2 min; stir in cheese."],
"rice-cooker-chicken-and-broccoli-cheesy-rice": ["Rinse rice; add to cooker with broth.", "Add bite-size chicken and broccoli on top.", "Run the white rice cycle.", "Stir in cheese until melted."],
"blender-broccoli-cheddar-soup": ["Sauté diced onion in butter; whisk in flour 1 min.", "Whisk in broth and milk; add broccoli; simmer 15 min.", "Blend until smooth (vent the lid).", "Stir in cheddar off the heat."],
"loaded-potato-soup": ["Cook chopped bacon; set aside.", "Simmer cubed potatoes in broth 15 min until tender; mash about half.", "Stir in milk, sour cream and most of the cheese.", "Top with bacon, remaining cheese and green onions."],
"canned-chicken-salad-wraps": ["Drain chicken; flake.", "Dice celery and apple; mix with chicken and mayo.", "Wrap in tortillas with lettuce."],
"microwave-buffalo-chicken-rice-bowls": ["Microwave rice (or use leftovers).", "Toss drained chicken with buffalo sauce; microwave 1 min.", "Top rice with chicken, diced celery, cheese and ranch."],
"italian-pasta-salad": ["Boil penne; rinse under cold water.", "Dice cucumber, tomatoes, pepper, pepperoni and mozzarella.", "Toss everything with Italian dressing.", "Chill at least 30 min."],
"italian-dressing-chicken-and-veggies": ["Marinate chicken in half the dressing 30 min (or overnight).", "Heat oven to 425°F. Chunk zucchini and potatoes; toss with remaining dressing.", "Roast potatoes 10 min, add chicken and zucchini, roast 20–25 min.", "Sprinkle with parmesan."],
"cauliflower-fried-rice": ["Cook cauliflower rice in a hot oiled pan 5 min to dry it out.", "Push aside; scramble eggs.", "Add frozen veg, soy and sesame oil; stir-fry 3 min.", "Top with green onions."],
"cauliflower-rice-burrito-bowls": ["Brown turkey; add taco seasoning and ½ cup water; simmer 5 min.", "Cook cauliflower rice in a hot pan 5 min.", "Warm beans.", "Build bowls with cauliflower rice, turkey, beans, salsa and avocado."],
"quinoa-power-bowls": ["Heat oven to 425°F. Cube sweet potatoes; roast with drained chickpeas and oil 25 min.", "Rinse quinoa; simmer in 3 cups water 15 min.", "Mix yogurt with lemon juice and salt.", "Build bowls over spinach; drizzle with the sauce."],
// --- Southern -------------------------------------------------------------
"oven-fried-chicken-drumsticks": ["Heat oven to 425°F; set a rack on a sheet pan.", "Whisk eggs with hot sauce; mix panko with paprika, garlic powder and salt.", "Dip drumsticks in egg then panko; set on the rack.", "Bake 40–45 min to 175°F."],
"chicken-and-dumplings": ["Sauté diced carrots and celery 5 min.", "Add broth and soup; bring to a simmer.", "Cut biscuits in quarters; drop into the pot; cover and simmer 15 min (don't lift the lid).", "Stir in shredded chicken; heat through."],
"gumbo": ["Whisk flour and oil in a heavy pot over medium; stir constantly 20–30 min until chocolate-brown.", "Add diced onion, pepper and celery; cook 5 min.", "Add broth, sliced sausage, chicken and Cajun seasoning; simmer 45 min.", "Shred chicken; serve over rice."],
"dirty-rice": ["Brown pork; add diced onion, pepper and celery, cook 5 min.", "Add rice and Cajun seasoning; stir 1 min.", "Add broth; bring to a boil, cover and simmer 18–20 min.", "Fluff and rest 5 min."],
"nashville-hot-chicken-sandwiches": ["Pound chicken thin; dip in egg then panko.", "Air fry at 400°F 12–14 min, flipping once.", "Warm hot sauce with paprika, brown sugar and 2 tbsp oil; brush over the chicken.", "Serve on buns with slaw."],
"slow-cooker-pulled-chicken-and-cornbread": ["Cook chicken in BBQ sauce on low 6 hours; shred.", "Mix cornbread mix with eggs and milk; bake at 400°F 15–20 min.", "Serve chicken with slaw and cornbread."],
"hoppin-john": ["Sauté ham, diced onion and celery 5 min.", "Add rice, broth and Cajun seasoning; boil, then cover and simmer 18 min.", "Fold in drained beans; heat through."],
"chicken-and-sausage-jambalaya-pasta": ["Boil penne.", "Brown diced chicken and sliced sausage with Cajun seasoning; add sliced pepper.", "Add cream; simmer 3 min; stir in parmesan.", "Toss with pasta."],
"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."],
"rice-cooker-red-beans-and-sausage": ["Rinse rice; add to cooker with broth.", "Add drained beans, sliced sausage, diced onion and Cajun seasoning.", "Run the white rice cycle; rest 10 min; fluff."],
// --- Mexican --------------------------------------------------------------
"chicken-tinga-tacos": ["Sauté sliced onion 5 min.", "Blend tomato sauce with chipotles; add to the pan and simmer 5 min.", "Stir in shredded chicken; simmer 5 min.", "Serve in warm tortillas with sour cream."],
"carnitas": ["Cut pork into chunks; add to slow cooker with orange juice, lime juice, cumin, quartered onion and salt.", "Cook on low 8–10 hours; shred.", "Spread on a sheet pan; broil 5–8 min until edges crisp.", "Serve in warm tortillas with cilantro."],
"pork-chile-verde": ["Cut pork into chunks; add to slow cooker with salsa verde, green chiles, diced onion and cumin.", "Cook on low 8 hours.", "Shred slightly; serve with rice and warm tortillas."],
"beef-barbacoa-bowls": ["Blend chipotles, lime juice, cumin and broth.", "Pour over beef in the slow cooker; cook on low 8 hours; shred.", "Serve over rice with warmed black beans."],
"sheet-pan-chicken-fajitas": ["Heat oven to 425°F.", "Slice chicken, peppers and onion; toss with oil and taco seasoning.", "Roast 20–25 min, stirring once.", "Serve in warm tortillas with sour cream."],
"beef-fajitas": ["Slice beef thin; toss with lime juice, cumin, chili powder and salt.", "Sear in a very hot pan 2–3 min; remove.", "Cook sliced peppers and onion until charred; add beef back.", "Serve in warm tortillas."],
"bean-and-cheese-burritos": ["Warm refried beans; stir in salsa.", "Spread on tortillas; top with cheese; roll up.", "Microwave 1 min (or wrap and freeze; microwave 2–3 min from frozen)."],
"beef-and-bean-burritos": ["Cook rice.", "Brown beef; add taco seasoning and water; simmer 5 min; stir in refried beans.", "Fill tortillas with beef-bean mix, rice and cheese; roll.", "Toast seam-side down in a skillet."],
"loaded-sheet-pan-nachos": ["Heat oven to 400°F.", "Brown beef with taco seasoning and a splash of water.", "Spread chips on a sheet pan; top with beef, beans and cheese.", "Bake 8–10 min; top with salsa, sour cream and jalapeño."],
"chicken-quesadillas": ["Dice pepper; shred chicken.", "Fill half of each tortilla with cheese, chicken and pepper; fold.", "Cook in a dry skillet 2–3 min per side.", "Serve with salsa."],
"air-fryer-chicken-taquitos": ["Mix shredded chicken, cream cheese, salsa verde and cheese.", "Warm tortillas 30 sec in the microwave so they don't crack.", "Roll a spoonful into each; spray with oil.", "Air fry seam-side down at 400°F 7–9 min."],
"green-chicken-enchiladas": ["Heat oven to 375°F.", "Mix chicken, chiles, half the salsa verde and half the cheese.", "Roll into warmed tortillas; place in a dish.", "Mix remaining salsa with sour cream; pour over, top with cheese; bake 20 min."],
"black-bean-enchiladas": ["Heat oven to 375°F.", "Mix beans, corn, diced onion and half the cheese.", "Roll into tortillas; place in a dish.", "Pour sauce over, top with cheese, bake 20 min."],
"tortilla-soup": ["Simmer broth, tomatoes, beans, corn and a little minced chipotle 15 min.", "Stir in shredded chicken.", "Serve topped with crushed chips and lime."],
"red-chile-pork-and-bean-stew": ["Cube pork; add to slow cooker with enchilada sauce, broth, beans and diced onion.", "Cook on low 8 hours.", "Serve topped with shredded cabbage and lime."],
"taco-rice-skillet": ["Brown beef; drain.", "Add rice, tomato sauce, taco seasoning and broth; bring to a boil.", "Cover and simmer 18–20 min.", "Top with cheese; cover until melted."],
"mexican-street-corn-chicken-bowls": ["Cook rice.", "Season chicken with chili powder and salt; cook 6–7 min per side; slice.", "Char corn in a hot dry pan; toss with mayo, lime juice and parmesan.", "Build bowls."],
"chorizo-and-potato-tacos": ["Dice potatoes; cook covered in a skillet with a splash of water 8 min.", "Add chorizo; cook uncovered 8 min until crisp.", "Serve in warm tortillas with diced onion, cilantro and salsa verde."],
"turkey-taco-lettuce-cups": ["Brown turkey; add taco seasoning and water; simmer 5 min.", "Separate lettuce leaves.", "Fill with turkey, diced tomato, cheese and salsa."],
"instant-mexican-rice-and-beans": ["Microwave rice with 3 cups water, covered, 10 min; rest 5.", "Stir in salsa, drained beans and cumin.", "Microwave 2 min more; top with cheese."],
"slow-cooker-chicken-burrito-filling": ["Add chicken, drained beans, corn, salsa and taco seasoning to the slow cooker.", "Cook on low 6 hours; shred.", "Fill tortillas, bowls or salads all week."],
"chicken-tostadas": ["Heat oven to 400°F; brush tortillas with oil and bake 8–10 min until crisp.", "Warm refried beans and shredded chicken.", "Spread beans on tostadas; top with chicken, shredded lettuce, salsa and sour cream."],
"black-bean-chilaquiles-bake": ["Heat oven to 375°F.", "Toss chips with sauce and beans in a baking dish.", "Top with cheese; crack eggs into small wells.", "Bake 15–18 min until whites are set."],
"rice-cooker-arroz-con-pollo": ["Rinse rice; add to cooker with tomato sauce, broth, cumin and diced onion.", "Lay chicken on top; run the white rice cycle.", "Stir in peas; rest 10 min; shred chicken into the rice."],
"blender-salsa-and-chicken-nacho-bowls": ["Blend tomatoes, onion, jalapeño, cilantro, lime juice and salt (pulse for chunky).", "Microwave rice and shredded chicken.", "Build bowls; top with salsa and crushed chips."],
// --- Caribbean ------------------------------------------------------------
"cuban-black-beans-and-rice": ["Cook rice.", "Sauté diced onion, pepper and garlic with cumin 6 min.", "Add beans (with liquid) and vinegar; simmer 15 min.", "Serve over rice."],
"cuban-mojo-pork-bowls": ["Mix orange juice, lime juice, garlic and cumin; pour over pork in the slow cooker.", "Cook on low 8 hours; shred.", "Serve with rice and warmed black beans."],
"cuban-style-pressed-sandwiches": ["Spread mustard on rolls; layer ham, turkey and cheese.", "Butter the outsides.", "Cook in a skillet with a heavy pan pressing down, 3–4 min per side."],
"jerk-pork-chops-with-pineapple": ["Cook rice.", "Rub chops with jerk seasoning; sear 4–5 min per side.", "Sear drained pineapple in the same pan until caramelized.", "Serve over rice with green onions."],
"air-fryer-jerk-drumsticks": ["Mix jerk seasoning, brown sugar and lime juice; coat drumsticks.", "Air fry at 380°F 25–28 min, flipping halfway.", "Serve with rice."],
"caribbean-coconut-curry-chicken": ["Cook rice.", "Sauté diced onion with curry powder and jerk seasoning 2 min.", "Add chicken and cubed potatoes; stir to coat.", "Add coconut milk and ½ cup water; simmer 25 min."],
"sofrito-rice-and-beans": ["Sauté diced pepper, onion and ham 5 min.", "Add rice, tomato sauce and drained beans; stir 1 min.", "Add broth; boil, then cover and simmer 20 min."],
"picadillo": ["Cook rice.", "Brown beef with diced onion and pepper.", "Add small-diced potatoes, tomato sauce, cumin and ½ cup water; simmer 15 min.", "Serve over rice."],
"tostones-and-black-bean-bowls": ["Cook rice; warm beans with garlic and lime juice.", "Slice green plantains 1 inch thick; fry 3 min per side.", "Smash each flat; fry again until crisp; salt.", "Serve with rice and beans."],
"jamaican-beef-patty-hand-pies": ["Heat oven to 375°F.", "Brown beef with diced onion, curry powder and jerk seasoning; stir in breadcrumbs and ¼ cup water.", "Press two crescent triangles into a rectangle; fill and fold; crimp.", "Bake 15–18 min."],
"rice-cooker-coconut-rice-and-peas": ["Rinse rice; add to cooker with coconut milk and water to the line.", "Add drained beans, green onions and jerk seasoning.", "Run the white rice cycle; fluff."],
"slow-cooker-caribbean-brown-stew-beef": ["Toss beef with brown sugar, soy and jerk seasoning.", "Add to slow cooker with diced onion, carrots and ½ cup water.", "Cook on low 8 hours; add drained beans for the last 30 min.", "Serve over rice."],
};

View file

@ -0,0 +1,81 @@
// Steps for recipes/extra/asia.ts, keyed by recipe id.
export const EXTRA_ASIA_STEPS: Record<string, string[]> = {
// --- Indian -------------------------------------------------------------------
"chicken-tikka-masala": ["Toss chicken with yogurt and half the curry powder; sear in batches.", "Sauté ginger and garlic; add remaining curry powder and tomato sauce; simmer 10 min.", "Stir in cream and the chicken; simmer 10 min.", "Serve with rice and warm naan."],
"chana-masala": ["Cook rice.", "Sauté diced onion with ginger and curry powder 5 min.", "Add crushed tomatoes and chickpeas; simmer 15 min, mashing a few.", "Finish with lemon juice."],
"aloo-matar-potatoes-and-peas": ["Sauté diced onion and cumin in oil 4 min.", "Add cubed potatoes and curry powder; stir 2 min.", "Add ½ cup water; cover and cook 15 min until tender.", "Stir in peas for 3 min; serve with naan."],
"keema-matar": ["Cook rice.", "Brown turkey with diced onion and ginger.", "Add curry powder and tomatoes; simmer 10 min.", "Stir in peas; cook 3 min."],
"spinach-and-ricotta-curry": ["Sauté diced onion with ginger and curry powder.", "Add spinach; wilt. Blend with cream until smooth.", "Bake spoonfuls of ricotta at 400°F 15 min until firm and golden.", "Fold ricotta into the sauce; serve with naan."],
"slow-cooker-chicken-korma": ["Add chicken, diced onion, ginger, curry powder and coconut milk to the slow cooker.", "Cook on low 6 hours.", "Stir in yogurt; shred slightly.", "Serve over rice."],
"tandoori-drumsticks": ["Mix yogurt, curry powder, paprika, lemon juice and salt; coat drumsticks (marinate up to overnight).", "Roast at 425°F on a rack 40 min to 175°F.", "Serve with rice and sliced cucumber."],
"egg-curry": ["Hard-boil eggs 10 min; peel; score lightly.", "Cook rice.", "Sauté sliced onions until golden; add curry powder and tomatoes; simmer 10 min.", "Add coconut milk and eggs; simmer 5 min."],
"vegetable-biryani": ["Sauté sliced onion with curry powder and cinnamon in butter (or add raw).", "Rinse rice; add to cooker with onion, frozen veg and water to the line.", "Run the white rice cycle.", "Fluff; serve with yogurt."],
"chicken-biryani": ["Toss chicken with yogurt and curry powder.", "Rinse rice; add to cooker with broth and diced onion.", "Lay chicken on top; run the white rice cycle.", "Rest 10 min; fluff together."],
"dal-tadka-with-naan": ["Simmer rinsed lentils with tomatoes and 3 cups water 20 min until soft.", "In a small pan, sizzle butter with cumin, garlic and pepper flakes 1 min.", "Pour the sizzling butter over the dal.", "Serve with warm naan."],
"microwave-masala-instant-noodles": ["Microwave noodles with 2 cups water per pack, 3 min.", "Stir in seasoning, curry powder, ketchup, peas and diced onion.", "Microwave 1–2 min more until peas are hot."],
"butter-chickpeas": ["Cook rice.", "Melt butter; cook ginger and curry powder 1 min.", "Add tomato paste, 1 cup water and chickpeas; simmer 10 min.", "Stir in cream."],
"samosa-hand-pies": ["Heat oven to 375°F. Boil cubed potatoes; drain and mash roughly.", "Sauté diced onion with curry powder; mix in potatoes and peas.", "Fill crescent squares; fold into triangles; crimp.", "Bake 15–18 min."],
// --- Chinese ------------------------------------------------------------------
"orange-chicken": ["Cook rice.", "Toss chicken cubes in cornstarch; spray with oil; air fry at 400°F 12–15 min.", "Simmer orange juice, soy, brown sugar and vinegar with 1 tsp cornstarch until glossy.", "Toss chicken in the sauce."],
"general-tsos-chicken": ["Cook rice.", "Toss chicken in cornstarch; fry in oil until crisp; drain.", "Simmer hoisin, soy, vinegar, sugar and pepper flakes with ¼ cup water 1 min.", "Toss chicken in the sauce."],
"sweet-and-sour-pork": ["Cook rice.", "Cube pork; toss in cornstarch; fry until crisp.", "Stir-fry peppers 2 min; add ketchup, vinegar, brown sugar and pineapple with juice; simmer until glossy.", "Toss pork back in."],
"mapo-tofu": ["Cook rice. Cube tofu.", "Brown pork; stir in gochujang and soy.", "Add broth and tofu; simmer 5 min gently.", "Thicken with cornstarch slurry; top with green onions."],
"egg-drop-soup-and-potstickers": ["Pan-fry potstickers per the bag (fry, steam, crisp).", "Bring broth to a simmer; stir in cornstarch slurry.", "Slowly drizzle in beaten eggs while stirring.", "Top soup with green onions; dip potstickers in soy."],
"air-fryer-potsticker-bowls": ["Air fry frozen potstickers at 380°F 10–12 min.", "Microwave rice.", "Toss slaw with soy, vinegar and sesame oil.", "Build bowls."],
"chicken-and-peanut-stir-fry": ["Cook rice.", "Stir-fry diced chicken until cooked; add pepper and garlic 2 min.", "Add hoisin and soy; toss.", "Stir in peanuts."],
"chicken-chow-mein": ["Boil spaghetti; drain and toss with sesame oil.", "Stir-fry sliced chicken; add shredded cabbage and carrots 3 min.", "Add noodles, oyster sauce and soy; toss over high heat 2 min."],
"beef-lo-mein": ["Boil spaghetti.", "Sear thinly sliced beef; remove.", "Stir-fry mushrooms and slaw mix 3 min.", "Add noodles, beef, oyster sauce, soy and brown sugar; toss."],
"moo-shu-pork-wraps": ["Scramble eggs; set aside.", "Brown pork; add slaw mix and cook 3 min.", "Add hoisin, soy and the eggs; toss.", "Wrap in warm tortillas with green onions."],
"hot-and-sour-soup": ["Simmer broth with sliced mushrooms 5 min.", "Add cubed tofu, vinegar, soy and pepper.", "Thicken with cornstarch slurry.", "Drizzle in beaten eggs while stirring."],
"garlic-green-beans-and-pork-rice-bowls": ["Cook rice.", "Stir-fry green beans in a hot oiled pan until blistered; remove.", "Brown pork with garlic; add soy and oyster sauce.", "Toss beans back in; serve over rice."],
"slow-cooker-mongolian-beef": ["Toss beef with cornstarch.", "Add to slow cooker with soy, brown sugar, garlic, ginger and ½ cup water.", "Cook on low 6 hours.", "Serve over rice with green onions."],
"rice-cooker-sweet-sausage-rice": ["Rinse rice; add to cooker with water to the line.", "Lay sliced sausage on top; drizzle soy and brown sugar.", "Run the white rice cycle; stir in peas for the last 10 min on warm.", "Top with green onions."],
"microwave-egg-fried-rice": ["Mix cold rice with frozen veg, soy and sesame oil in a large bowl.", "Microwave 2 min.", "Make a well; crack in eggs; microwave 1 min, stir, 30–60 sec more.", "Mix and top with green onions."],
"honey-shrimp": ["Cook rice; steam broccoli.", "Toss shrimp in cornstarch; pan-fry until crisp.", "Mix mayo, honey and lemon juice.", "Toss shrimp in the sauce."],
// --- Japanese -----------------------------------------------------------------
"chicken-katsu-curry": ["Make curry: melt butter, whisk in flour and curry powder 2 min; whisk in broth.", "Add cubed potatoes and carrots; simmer 15 min.", "Pound chicken thin; dip in egg then panko; air fry at 400°F 12 min.", "Slice chicken; serve over rice with curry."],
"tonkatsu-pork-cutlets": ["Pound chops thin; dredge in flour, egg, then panko.", "Fry in oil 3–4 min per side until golden.", "Mix ketchup and Worcestershire for sauce.", "Serve with shredded cabbage and rice."],
"gyudon-beef-and-onion-bowl": ["Cook rice.", "Simmer sliced onions in broth, soy, brown sugar and ginger 5 min.", "Add thinly sliced beef; simmer 3–4 min.", "Spoon over rice with the broth."],
"miso-soup-and-salmon-rice-bowls": ["Cook rice.", "Simmer 4 cups water; add cubed tofu; whisk in miso off the heat.", "Flake drained salmon.", "Serve rice topped with salmon, green onions and seaweed, with soup."],
"miso-glazed-salmon": ["Heat oven to 400°F. Mix miso, honey and soy.", "Brush on salmon; bake 12–15 min.", "Cook rice; steam broccoli.", "Broil salmon 1–2 min to caramelize."],
"yakisoba-stir-fried-noodles": ["Boil spaghetti.", "Brown pork; add shredded cabbage and carrots; cook 4 min.", "Add noodles, soy, oyster sauce and Worcestershire; toss over high heat."],
"japanese-hamburg-steak": ["Cook rice.", "Mix beef, panko, egg and diced onion; shape 4 patties with a dent in the middle.", "Pan-fry 5 min per side; cover the last 3 min.", "Add ketchup, Worcestershire and ¼ cup water to the pan; simmer into a glaze."],
"okonomiyaki-cabbage-pancakes": ["Shred cabbage; mix with flour, eggs and ½ cup water.", "Spread into a pan in thick rounds; lay bacon strips on top.", "Cook 5 min per side over medium.", "Drizzle with ketchup-Worcestershire sauce and mayo."],
"spam-musubi": ["Cook rice; season with rice vinegar.", "Slice Spam into 8; sear; glaze with soy and sugar.", "Press rice into blocks the size of the Spam slices.", "Top with Spam and wrap with a seaweed strip."],
"chicken-yakitori-rice-bowls": ["Cook rice.", "Simmer soy and brown sugar until syrupy.", "Air fry chicken pieces and green onion chunks at 400°F 12 min, brushing with sauce.", "Serve over rice."],
"rice-cooker-japanese-curry-rice": ["Rinse rice; add to cooker with broth and curry powder.", "Add diced chicken, carrots and potatoes on top.", "Run the white rice cycle.", "Stir and serve."],
"air-fryer-panko-shrimp-and-slaw": ["Cook rice.", "Dip shrimp in egg then panko; air fry at 400°F 8 min.", "Toss slaw with mayo and rice vinegar.", "Serve together."],
// --- Korean -------------------------------------------------------------------
"korean-fried-chicken-drumsticks": ["Toss drumsticks in cornstarch and salt.", "Air fry at 380°F 25 min, then 400°F 5 min to crisp.", "Simmer gochujang, honey, soy and garlic 2 min.", "Toss chicken in the glaze."],
"dakgalbi-spicy-chicken-stir-fry": ["Cook rice.", "Mix gochujang, soy and brown sugar; coat chicken.", "Stir-fry chicken with chopped cabbage and thin sweet potato slices 12–15 min.", "Serve with rice."],
"japchae-glass-noodle-stir-fry": ["Soak or boil noodles per the pack; drain and toss with sesame oil.", "Stir-fry thin beef; remove. Stir-fry carrots, mushrooms and spinach.", "Add noodles, beef, soy and sugar; toss well.", "Finish with sesame oil."],
"kimchi-jjigae-kimchi-stew": ["Cook rice.", "Brown pork with chopped kimchi and gochujang 5 min.", "Add broth; simmer 15 min.", "Add cubed tofu; simmer 5 min; top with green onions."],
"spicy-gochujang-gnocchi": ["Hard-boil eggs; peel.", "Simmer broth, gochujang, sugar and soy.", "Add gnocchi; cook until the sauce is thick and glossy, 6–8 min.", "Add eggs; top with green onions."],
"budae-jjigae-army-stew": ["Slice Spam, hot dogs and tofu.", "Arrange in a wide pot with kimchi; add broth and gochujang.", "Simmer 10 min; add ramen noodles and cook 3 min.", "Top with cheese; serve bubbling."],
"korean-beef-tacos": ["Brown beef; add soy, brown sugar and sesame oil.", "Mix mayo and sriracha.", "Warm tortillas; fill with beef, chopped kimchi and spicy mayo."],
"kimchi-pancakes": ["Mix chopped kimchi (with juice), flour, egg, green onions and ⅓ cup water.", "Spread thin in an oiled pan; cook 3–4 min per side until crisp.", "Dip in soy mixed with vinegar."],
"gochujang-tofu-rice-bowls": ["Press and cube tofu; toss with cornstarch.", "Air fry at 400°F 15 min.", "Simmer gochujang, honey and soy; toss tofu in it.", "Serve over rice with sliced cucumber."],
"rice-cooker-korean-veggie-rice": ["Rinse rice; add to cooker with water to the line.", "Top with shredded carrots, mushrooms and spinach.", "Run the white rice cycle.", "Mix soy, gochujang and sesame oil; spoon over to taste."],
"spicy-pork-bulgogi-jeyuk": ["Slice pork thin; mix with gochujang, soy and sugar.", "Stir-fry with sliced onion over high heat 8 min.", "Serve with rice and lettuce leaves for wrapping."],
// --- Thai & Vietnamese ------------------------------------------------------------
"pad-thai": ["Soak noodles in hot water 8 min; drain.", "Mix fish sauce, brown sugar and juice of 1 lime.", "Stir-fry sliced chicken; push aside and scramble eggs.", "Add noodles and sauce; toss 2 min. Top with peanuts, green onions, slaw and lime."],
"shrimp-pad-thai": ["Soak noodles in hot water 8 min; drain.", "Mix fish sauce, brown sugar and juice of 1 lime.", "Cook shrimp 2 min; push aside and scramble eggs.", "Add noodles and sauce; toss. Top with peanuts, green onions and lime."],
"drunken-noodles-pad-kee-mao": ["Soak noodles; drain.", "Stir-fry sliced jalapeños and chicken until cooked.", "Add pepper strips, noodles, oyster sauce, soy and fish sauce; toss over high heat.", "Fold in basil."],
"pad-see-ew": ["Soak noodles; drain.", "Stir-fry sliced chicken; add broccoli 3 min.", "Push aside; scramble eggs.", "Add noodles, soy, oyster sauce and brown sugar; let them char a little before tossing."],
"shortcut-green-curry": ["Cook rice.", "Fry curry paste in a little coconut milk 1 min.", "Add chicken; cook 5 min. Add remaining coconut milk, salsa verde and sliced zucchini; simmer 10 min.", "Season with fish sauce; stir in basil."],
"panang-curry": ["Cook rice.", "Fry curry paste in a splash of coconut milk 1 min.", "Add chicken, then remaining coconut milk, peanut butter, fish sauce and brown sugar; simmer 10 min.", "Add sliced pepper for 3 min."],
"tom-kha-gai-coconut-chicken-soup": ["Simmer broth with ginger 5 min.", "Add coconut milk, sliced chicken and mushrooms; simmer gently 10 min.", "Season with fish sauce and lime juice.", "Top with cilantro."],
"thai-basil-chicken-gai-pad-krapow": ["Cook rice.", "Stir-fry garlic and sliced jalapeños 30 sec; add chicken and cook through.", "Add oyster sauce and fish sauce; toss in basil.", "Serve with fried eggs."],
"thai-larb-lettuce-cups": ["Cook rice.", "Cook chicken with a splash of water, breaking it up.", "Off the heat, stir in lime juice, fish sauce, pepper flakes, cilantro and green onions.", "Spoon into lettuce leaves."],
"sweet-chili-chicken-rice-bowls": ["Cook rice; steam broccoli.", "Toss chicken cubes in cornstarch; air fry at 400°F 12–14 min.", "Toss in sweet chili sauce.", "Serve with rice, broccoli and cucumber."],
"thai-peanut-noodle-salad": ["Cook noodles; rinse cold.", "Whisk peanut butter, soy, lime juice, honey and warm water into a dressing.", "Toss noodles with slaw, shredded carrots and cucumber.", "Toss with dressing; chill."],
"slow-cooker-thai-peanut-chicken": ["Whisk peanut butter, coconut milk, soy, honey, lime and curry paste.", "Pour over chicken in the slow cooker; cook on low 6 hours.", "Shred; serve over rice."],
"thai-pineapple-fried-rice": ["Cook shrimp 2 min; remove.", "Scramble eggs; add cold rice, curry powder and fish sauce.", "Stir-fry until hot; add pineapple and shrimp.", "Top with peanuts and green onions."],
"banh-mi-bowls": ["Toss shredded carrots and sliced cucumber with rice vinegar and a pinch of sugar.", "Cook rice.", "Brown pork with fish sauce and brown sugar until caramelized.", "Build bowls; drizzle sriracha mayo; top with cilantro."],
"chicken-pho": ["Simmer broth with ginger and cinnamon 15 min; season with fish sauce.", "Soak or boil noodles; divide among bowls.", "Add shredded chicken; ladle hot broth over.", "Top with herbs and lime."],
};

View file

@ -0,0 +1,49 @@
// Steps for recipes/extra/breakfasts.ts, keyed by recipe id.
export const EXTRA_BREAKFAST_STEPS: Record<string, string[]> = {
"classic-bacon-eggs-and-toast": ["Cook bacon in a skillet over medium until crisp; drain.", "Fry or scramble eggs in a little of the bacon fat.", "Toast and butter the bread; serve together."],
"sausage-egg-and-cheese-muffins": ["Shape sausage into 4 thin patties; cook 3–4 min per side.", "Fry eggs in the same pan (break the yolks if you like).", "Toast muffins; stack patty, egg and cheese.", "Wrap and freeze extras; microwave 1–2 min to reheat."],
"ham-and-cheese-omelets": ["Whisk 2 eggs per omelet with a pinch of salt.", "Pour into a buttered pan over medium; pull edges in as they set.", "When nearly set, add ham, cheese and green onions to one half.", "Fold and slide onto a plate."],
"denver-scramble": ["Dice pepper and onion; sauté with ham 4 min.", "Pour in beaten eggs; stir gently over medium-low.", "Fold in cheese just before the eggs set."],
"hash-brown-breakfast-casserole": ["Heat oven to 375°F; grease a 9×13 dish.", "Brown sausage; spread hash browns and sausage in the dish.", "Whisk eggs and milk; pour over, top with cheese.", "Bake 40–45 min until set in the center."],
"biscuit-breakfast-sandwiches": ["Bake biscuits per the can.", "Shape sausage into 8 patties; cook 3–4 min per side.", "Fry eggs; split biscuits and fill with patty, egg and cheese."],
"bagel-egg-sandwiches": ["Toast bagels.", "Fry eggs in butter; add a slice of cheese for the last 30 sec, covered.", "Sandwich eggs in the bagels."],
"bagels-and-veggie-cream-cheese": ["Soften cream cheese.", "Finely chop green onion and cucumber; stir in with a pinch of salt.", "Spread on bagels (toasted if you like)."],
"microwave-egg-muffin-mugs": ["Grease 4 mugs; crack in 1–2 eggs each and whisk with a fork.", "Stir in ham, cheese and chopped spinach.", "Microwave 60 sec, stir, then 30–45 sec more until set."],
"microwave-breakfast-quesadilla": ["Whisk eggs in a bowl; microwave 1 min, stir, 30 sec more.", "Spoon egg and cheese onto half of each tortilla; fold.", "Microwave 30 sec to melt; serve with salsa."],
"air-fryer-breakfast-potatoes-and-sausage": ["Dice potatoes and onion; toss with oil, paprika and salt.", "Air fry at 400°F 12 min.", "Add sliced sausage; cook 8–10 min more, shaking once."],
"air-fryer-egg-bites": ["Cook bacon and crumble; chop spinach.", "Whisk eggs with milk; stir in bacon, spinach and cheese.", "Pour into silicone muffin cups ¾ full.", "Air fry at 300°F 12–15 min until just set."],
"spinach-mushroom-frittata": ["Heat oven to 375°F.", "Sauté onion and mushrooms in an oven-safe skillet until golden; wilt in spinach.", "Pour in beaten eggs with parmesan; cook 2 min on the stove.", "Bake 12–15 min until set. Cut into wedges."],
"sheet-pan-pancakes": ["Heat oven to 425°F; grease a sheet pan.", "Whisk flour, sugar and baking powder; whisk in milk, eggs and melted butter.", "Spread on the pan; scatter berries on top.", "Bake 12–15 min until golden; cut into squares and serve with syrup."],
"chocolate-chip-pancakes": ["Whisk flour, sugar and baking powder; whisk in milk and eggs.", "Fold in chocolate chips.", "Cook ¼-cup scoops on a greased pan over medium; flip when bubbly.", "Serve with syrup."],
"french-toast": ["Whisk eggs, milk and cinnamon in a shallow dish.", "Dip bread slices briefly on both sides.", "Cook in butter over medium 2–3 min per side until golden.", "Serve with syrup."],
"overnight-french-toast-bake": ["Cube bread into a greased 9×13 dish.", "Whisk eggs, milk, brown sugar and cinnamon; pour over. Cover and refrigerate overnight.", "Bake uncovered at 350°F for 40–45 min.", "Drizzle with melted butter; serve warm."],
"pumpkin-spice-oatmeal": ["Bring milk to a simmer.", "Stir in oats; cook 5 min.", "Stir in pumpkin, brown sugar and cinnamon; cook 1 min more."],
"apple-cinnamon-oatmeal": ["Dice apples.", "Simmer apples in the milk 3 min.", "Add oats, cinnamon and brown sugar; cook 5 min, stirring."],
"baked-oatmeal-squares": ["Heat oven to 350°F; grease an 8×8 or 9×13 dish.", "Mash bananas; whisk in eggs, milk and honey.", "Stir in oats, baking powder and cinnamon; fold in berries.", "Bake 35–40 min until set; cut into squares."],
"slow-cooker-apple-pie-oats": ["Grease the slow cooker; add diced apples.", "Stir in oats, milk, brown sugar and cinnamon; dot with butter.", "Cook on low 6–7 hours (overnight).", "Stir and serve."],
"microwave-chocolate-pb-oats": ["Mix oats, milk and cocoa in a large bowl.", "Microwave 2–3 min, stirring halfway.", "Stir in peanut butter and honey."],
"yogurt-granola-parfaits": ["Thaw berries slightly.", "Layer yogurt, berries and granola in jars or cups.", "Drizzle with honey. Add granola last if making ahead so it stays crunchy."],
"pb-banana-toast": ["Toast bread (optional).", "Spread with peanut butter.", "Top with sliced banana and a drizzle of honey."],
"cinnamon-apple-crescent-bites": ["Heat oven to 375°F.", "Slice apples into 8 wedges; toss with sugar and cinnamon.", "Wrap each wedge in a crescent triangle; brush with melted butter.", "Bake 12–15 min until golden."],
"cornbread-breakfast-muffins": ["Heat oven to 400°F; line a muffin tin.", "Mix cornbread mix, eggs and milk.", "Fold in ham and cheese; fill cups ⅔ full.", "Bake 15–18 min."],
"chilaquiles-rojos": ["Warm enchilada sauce in a large skillet.", "Add chips; toss to coat and simmer 2 min (soft but not soggy).", "Fry eggs in another pan.", "Top chips with eggs, sour cream and cilantro."],
"chorizo-breakfast-tacos": ["Cook chorizo, breaking it up, 5 min.", "Add beaten eggs; scramble together.", "Warm tortillas; fill and top with salsa and cilantro."],
"migas": ["Cut tortillas into strips; fry in oil until crisp.", "Add diced pepper and onion; cook 3 min.", "Pour in beaten eggs; scramble gently.", "Fold in cheese; serve with salsa."],
"breakfast-burrito-bowls": ["Dice potatoes; pan-fry in oil 12–15 min until crisp.", "Scramble eggs.", "Warm beans.", "Build bowls with potatoes, eggs, beans, salsa and cheese."],
"turkish-eggs-cilbir": ["Stir garlic and a pinch of salt into the yogurt; spread on plates.", "Poach eggs in simmering water 3 min.", "Melt butter with red pepper flakes until sizzling.", "Set eggs on the yogurt, spoon the chili butter over, serve with warm pita."],
"ful-medames": ["Warm beans (with liquid) in a pot; mash roughly.", "Stir in garlic, cumin and lemon juice.", "Drizzle with olive oil; top with diced tomatoes.", "Scoop up with pita."],
"masala-omelet": ["Finely dice onion, tomato, jalapeño and cilantro.", "Whisk into the eggs with curry powder and salt.", "Cook in an oiled pan over medium, folding when nearly set."],
"savory-upma-style-couscous": ["Toast peanuts in butter; add diced onion and curry powder, cook 3 min.", "Add peas and 1½ cups water; bring to a boil.", "Stir in couscous, cover, turn off heat, rest 5 min.", "Fluff and serve."],
"japanese-breakfast-rice-bowls": ["Cook rice.", "Simmer 3 cups water; whisk in miso off the heat for soup.", "Fry eggs; flake drained salmon.", "Serve rice topped with salmon, egg, green onions and seaweed, with miso soup."],
"spam-and-egg-rice-bowls": ["Cook rice.", "Slice Spam; sear until browned.", "Add soy and brown sugar; toss to glaze.", "Serve over rice with fried eggs and crumbled seaweed."],
"korean-steamed-egg-gyeran-jjim": ["Whisk eggs with broth and a pinch of salt.", "Pour into a microwave-safe bowl; cover loosely.", "Microwave on 50% power 4–6 min until puffed and set.", "Top with green onions and sesame oil; serve with rice."],
"thai-jok-rice-porridge": ["Rinse rice; add to rice cooker with broth and ginger.", "Roll pork into small meatballs; drop on top.", "Run the porridge setting.", "Season with fish sauce; top with green onions."],
"caribbean-tuna-and-pepper-scramble": ["Sauté diced onion, pepper and tomato with jerk seasoning 5 min.", "Add drained tuna; cook 2 min.", "Pour in beaten eggs; scramble until set."],
"grits-and-eggs": ["Bring milk and 2 cups water to a boil; whisk in grits.", "Simmer 5 min, stirring; stir in butter and cheese.", "Fry eggs and serve on top."],
"country-ham-biscuits": ["Bake biscuits per the can.", "Warm ham in a skillet.", "Mix butter and mustard.", "Split biscuits, spread with mustard butter, fill with ham."],
"italian-ricotta-toast": ["Toast bread.", "Spread thickly with ricotta.", "Top with thawed berries and a drizzle of honey."],
"greek-yogurt-honey-bowls": ["Spoon yogurt into bowls.", "Top with sliced banana and granola.", "Drizzle generously with honey."],
"soy-egg-congee": ["Soft-boil eggs 7 min; peel and soak in soy sauce with a splash of water (overnight is best).", "Add rinsed rice and broth to the slow cooker; cook on low 6–8 hours.", "Stir until creamy; season.", "Top with halved soy eggs, green onions and sesame oil."],
"scallion-pancakes": ["Mix flour with ¾ cup boiling water into a dough; rest 20 min.", "Divide in 4; roll thin, brush with sesame oil, scatter green onions, roll up and coil, then roll flat.", "Pan-fry in oil 3 min per side until crisp.", "Dip in soy sauce mixed with rice vinegar."],
};

View file

@ -0,0 +1,56 @@
// Steps for recipes/extra/mediterranean.ts, keyed by recipe id.
export const EXTRA_MEDITERRANEAN_STEPS: Record<string, string[]> = {
// --- Italian --------------------------------------------------------------
"classic-lasagna": ["Heat oven to 375°F. Boil noodles; brown beef and stir in marinara.", "Mix ricotta, egg and half the parmesan.", "Layer sauce, noodles, ricotta, mozzarella; repeat 3 times, ending with sauce and cheese.", "Cover with foil; bake 25 min, uncover, bake 20 min. Rest 15 min."],
"spinach-ricotta-lasagna-roll-ups": ["Heat oven to 375°F; boil noodles.", "Mix ricotta, chopped spinach, half the mozzarella and parmesan.", "Spread on noodles, roll up, set seam-down on marinara in a dish.", "Top with sauce and cheese; bake 25 min."],
"chicken-parmesan": ["Heat oven to 425°F. Pound chicken thin.", "Dip in egg, then panko mixed with parmesan; brown in oil 2–3 min per side.", "Move to a dish; top with marinara and mozzarella; bake 15 min.", "Serve over spaghetti."],
"air-fryer-chicken-parm": ["Pound chicken thin; dip in egg then breadcrumbs.", "Air fry at 400°F 10 min, flip.", "Top with marinara and mozzarella; air fry 3–4 min more.", "Serve with penne and extra sauce."],
"zucchini-parmesan": ["Heat oven to 425°F. Slice zucchini into rounds.", "Dip in egg, then panko and parmesan; bake on a sheet 15 min.", "Layer with marinara and mozzarella in a dish; bake 15 min.", "Serve with spaghetti."],
"baked-penne-with-sausage": ["Heat oven to 375°F; boil penne 2 min short.", "Brown sliced sausage; add marinara, crushed tomatoes and Italian seasoning.", "Toss with pasta and half the mozzarella; top with the rest.", "Bake 20 min."],
"ham-and-pea-alfredo": ["Boil pasta; add peas for the last 2 min; drain.", "Warm Alfredo sauce with ham.", "Toss with pasta; top with parmesan."],
"cheese-tortellini-soup": ["Simmer crushed tomatoes, broth and Italian seasoning 10 min.", "Add tortellini; cook per the package.", "Stir in cream and spinach until wilted."],
"sausage-tortellini-skillet": ["Brown sliced sausage.", "Add marinara, cream and ½ cup water; bring to a simmer.", "Add tortellini; cover and cook 5–7 min.", "Stir in spinach."],
"brown-butter-gnocchi-with-spinach": ["Pan-fry gnocchi in a little butter, undisturbed, until golden on one side; toss and repeat.", "Add remaining butter; cook until it smells nutty.", "Add garlic and spinach; toss until wilted.", "Top with parmesan."],
"sheet-pan-gnocchi-and-sausage": ["Heat oven to 425°F.", "Toss gnocchi (straight from the pack), sliced sausage, peppers and onion with oil and Italian seasoning.", "Roast 20–25 min, stirring once, until gnocchi are crisp."],
"italian-wedding-soup": ["Sauté diced onion and carrots 5 min.", "Add broth; bring to a boil.", "Add pasta and meatballs; simmer 10 min.", "Stir in spinach; top with parmesan."],
"minestrone": ["Sauté diced onion, carrots and celery 5 min.", "Add tomatoes, broth, Italian seasoning and diced zucchini; simmer 10 min.", "Add beans and pasta; cook 10 min."],
"spaghetti-aglio-e-olio": ["Boil spaghetti; save 1 cup pasta water.", "Warm oil with garlic and pepper flakes on low until garlic is pale gold.", "Toss pasta in the oil with splashes of pasta water.", "Finish with parmesan."],
"pasta-carbonara": ["Boil spaghetti; save 1 cup pasta water.", "Cook chopped bacon until crisp; turn off heat.", "Whisk eggs, parmesan and lots of pepper.", "Toss hot pasta in the bacon pan, then quickly stir in the egg mix and pasta water until creamy (don't let it scramble)."],
"creamy-tuscan-chicken": ["Boil penne.", "Season chicken; sear 5–6 min per side; remove and slice.", "Sauté garlic; add tomatoes and cream; simmer 3 min; stir in spinach and parmesan.", "Return chicken; serve over pasta."],
"chicken-cacciatore": ["Brown chicken; remove.", "Sauté sliced pepper, mushrooms and onion 6 min.", "Add crushed tomatoes and Italian seasoning; return chicken; simmer covered 30 min.", "Serve over spaghetti."],
"slow-cooker-italian-meatballs": ["Add frozen meatballs and marinara to the slow cooker.", "Cook on low 4–6 hours.", "Serve over spaghetti with parmesan."],
"pizza-pasta-bake": ["Heat oven to 375°F; boil penne 2 min short.", "Toss with marinara, Italian seasoning, half the pepperoni and half the cheese.", "Top with remaining cheese and pepperoni.", "Bake 20 min."],
"calzones": ["Heat oven to 400°F.", "Roll each dough into a rectangle; cut in half.", "Fill half of each piece with ricotta, mozzarella and pepperoni; fold and crimp; cut a vent.", "Bake 15–18 min; dip in warm marinara."],
"margherita-flatbreads": ["Heat oven to 425°F.", "Spread naan with tomato sauce; top with mozzarella and sliced tomatoes.", "Bake 8–10 min; scatter torn basil."],
"pasta-primavera": ["Boil penne; add peas for the last 2 min.", "Sauté sliced zucchini and pepper in oil 5 min.", "Toss with pasta, lemon juice, parmesan and a splash of pasta water."],
"microwave-mug-lasagna": ["Break noodles into pieces; microwave in water 6–8 min until tender; drain.", "Layer noodles, marinara, ricotta and mozzarella in 4 large mugs.", "Microwave 2 min until bubbly."],
"rice-cooker-mushroom-risotto": ["Sauté diced onion and mushrooms in butter (or skip and add raw).", "Add rice, broth and mushrooms to the cooker.", "Run the porridge or white rice cycle.", "Stir hard for 1 min; stir in parmesan."],
"blender-roasted-red-pepper-pasta": ["Char peppers in a hot pan or under the broiler; peel off blackened bits.", "Boil penne; save ½ cup water.", "Blend peppers with cream cheese, parmesan, garlic and oil.", "Toss pasta with sauce, loosening with pasta water."],
// --- Mediterranean ----------------------------------------------------------
"greek-chicken-souvlaki-bowls": ["Marinate cubed chicken in lemon juice, oil and Italian seasoning 15+ min.", "Cook rice; pan-sear chicken 8–10 min.", "Grate cucumber into yogurt with salt for tzatziki.", "Build bowls."],
"greek-lemon-chicken-soup-avgolemono": ["Simmer rice in broth 15 min.", "Whisk eggs with lemon juice.", "Slowly whisk 2 cups hot broth into the eggs, then stir it back into the pot (don't boil).", "Add shredded chicken; heat through gently."],
"spanakopita-hand-pies": ["Heat oven to 375°F.", "Wilt spinach with diced onion; squeeze dry; mix with feta, ricotta and egg.", "Fill crescent squares, fold into triangles, crimp.", "Bake 15–18 min."],
"greek-couscous-salad": ["Pour 1½ cups boiling water over couscous; cover 5 min; fluff.", "Dice cucumber and tomatoes; drain chickpeas.", "Toss everything with lemon juice, olive oil and feta."],
"sheet-pan-greek-chicken-and-potatoes": ["Heat oven to 425°F.", "Toss chicken and potato wedges with oil, lemon juice, garlic, Italian seasoning and salt.", "Roast 40 min, flipping potatoes halfway.", "Top with feta."],
"mediterranean-quinoa-bowls": ["Rinse quinoa; simmer in 3 cups water 15 min.", "Dice cucumber and tomatoes; drain chickpeas.", "Mix yogurt with lemon juice.", "Build bowls; top with feta."],
"spanish-chickpea-and-spinach-stew": ["Fry bread cubes and garlic in oil until golden; set aside.", "Add paprika and cumin; then tomatoes and chickpeas; simmer 10 min.", "Mash a little; stir in spinach.", "Top with the garlic croutons."],
"spanish-chorizo-and-potato-skillet": ["Cube potatoes; cook covered with a splash of water 8 min.", "Add chorizo, onion and pepper; cook uncovered until crisp.", "Make wells; crack in eggs; cover until set."],
"tuscan-white-bean-soup": ["Sauté diced carrots, celery and garlic 5 min.", "Add beans and broth; simmer 15 min.", "Mash some beans to thicken; stir in spinach.", "Top with parmesan."],
"air-fryer-mediterranean-salmon": ["Brush salmon with oil, lemon juice and Italian seasoning.", "Air fry at 400°F 8–10 min.", "Make couscous with 1½ cups boiling water.", "Serve with diced cucumber."],
"slow-cooker-greek-chicken-pitas": ["Add chicken, lemon juice, garlic, Italian seasoning and ¼ cup water to the slow cooker.", "Cook on low 6 hours; shred.", "Mix yogurt with grated cucumber and salt.", "Fill pitas with chicken and sauce."],
"blender-hummus-plates": ["Blend chickpeas (save the liquid), lemon juice, garlic, cumin and oil until smooth, adding liquid as needed.", "Cut pita into wedges; slice cucumber and carrots.", "Serve hummus drizzled with oil, with pita and veg."],
// --- Middle Eastern ----------------------------------------------------------
"kofta-kebab-plates": ["Cook rice.", "Grate onion; mix with beef, cumin, cinnamon and salt; shape into logs.", "Pan-fry 8–10 min, turning.", "Serve with rice, cucumber and yogurt sauce."],
"chicken-kofta-pitas": ["Mix chicken, grated onion, chopped cilantro, cumin and salt; shape into logs.", "Air fry at 400°F 10–12 min.", "Serve in pitas with yogurt and diced tomatoes."],
"lebanese-lentil-soup": ["Sauté diced onion and carrots in oil 5 min; add cumin.", "Add lentils and broth; simmer 20 min.", "Blend until smooth.", "Stir in lemon juice."],
"chicken-shawarma-rice-bowls": ["Cook rice.", "Toss sliced chicken with cumin, paprika, cinnamon, salt and oil; sear 8–10 min.", "Mix yogurt with garlic and salt; shred cabbage.", "Build bowls."],
"fattoush-chicken-salad": ["Heat oven to 400°F; cut pita into squares, toss with oil, bake 8 min until crisp.", "Chop lettuce, cucumber and tomatoes; shred chicken.", "Whisk lemon juice, olive oil and salt.", "Toss everything just before eating."],
"shakshuka-with-chickpeas-and-feta": ["Sauté diced pepper 5 min; add cumin and paprika.", "Add tomatoes and chickpeas; simmer 10 min.", "Crack in eggs; cover 6–8 min.", "Top with feta; serve with pita."],
"moroccan-chickpea-tagine": ["Sauté diced onion with cumin and cinnamon.", "Add cubed sweet potatoes, tomatoes, chickpeas and 1 cup water; simmer 20 min.", "Make couscous with 1½ cups boiling water.", "Serve the stew over couscous."],
"slow-cooker-moroccan-chicken": ["Add chicken, chickpeas, sliced carrots, diced onion, spices and broth to the slow cooker.", "Cook on low 6 hours.", "Make couscous; serve the chicken and sauce over it."],
"herbed-rice-and-beans": ["Rinse rice; add to cooker with water to the line and butter.", "Run the white rice cycle.", "Fold in drained beans, chopped cilantro, green onions and cumin; let the crispy bottom set 10 min on warm."],
"microwave-couscous-tabbouleh-bowls": ["Microwave 1½ cups water to boiling; pour over couscous, cover 5 min.", "Dice cucumber and tomatoes; chop cilantro.", "Fluff couscous; toss with veg, chickpeas, lemon juice and oil."],
};

View file

@ -0,0 +1,22 @@
// Steps for recipes/extra/seafood.ts, keyed by recipe id.
export const EXTRA_SEAFOOD_STEPS: Record<string, string[]> = {
"salmon-patties": ["Boil cubed potatoes; mash with a little butter.", "Mix drained salmon, breadcrumbs, egg and mayo; form 6 patties.", "Pan-fry 3–4 min per side until golden.", "Serve with lemon, peas and potatoes."],
"tuna-salad-sandwiches": ["Mix drained tuna, mayo and diced celery.", "Season with salt and pepper.", "Sandwich with lettuce."],
"fish-stick-tacos": ["Air fry fish sticks at 400°F 10–12 min.", "Mix sour cream with lime juice.", "Warm tortillas; fill with fish, slaw, crema and salsa."],
"air-fryer-fish-and-chips": ["Air fry fries at 400°F 15 min; keep warm.", "Dip fish in egg then seasoned panko.", "Air fry at 400°F 10–12 min.", "Mix mayo with lemon juice for tartar; serve."],
"new-england-clam-chowder": ["Cook chopped bacon; remove.", "Sauté diced onion and celery in the fat; stir in flour.", "Add clam juice from the cans, cubed potatoes and milk; simmer 15 min.", "Stir in clams and cream; heat through. Top with bacon."],
"garlic-butter-shrimp-and-rice": ["Cook rice; steam broccoli.", "Melt butter; cook garlic and pepper flakes 30 sec.", "Add shrimp; cook 2 min per side.", "Squeeze lemon over; serve on rice."],
"shrimp-scampi-pasta": ["Boil spaghetti.", "Melt butter; cook garlic and pepper flakes 30 sec.", "Add shrimp; cook 3–4 min; add lemon juice.", "Toss with pasta and parmesan."],
"tuna-pasta-puttanesca": ["Boil spaghetti.", "Warm oil with garlic and pepper flakes; add crushed tomatoes; simmer 10 min.", "Stir in drained tuna.", "Toss with pasta."],
"baked-pollock-provencal": ["Heat oven to 400°F.", "Mix tomatoes, garlic, Italian seasoning and oil in a baking dish.", "Nestle fish in the sauce; bake 15–18 min until flaky.", "Make couscous; serve with the fish and sauce."],
"teriyaki-salmon-sheet-pan": ["Cook rice. Heat oven to 400°F.", "Toss green beans with oil; spread on a sheet with salmon.", "Brush salmon with teriyaki; bake 12–15 min.", "Brush again before serving."],
"korean-spicy-tuna-mayo-rice-bowls": ["Microwave rice.", "Mix drained tuna, mayo and gochujang.", "Fry or microwave eggs.", "Top rice with tuna, egg and crumbled seaweed."],
"thai-fish-cakes": ["Pulse fish, curry paste, egg and cornstarch to a paste.", "Fold in finely chopped green beans.", "Shape small patties; pan-fry 2–3 min per side.", "Serve with sweet chili sauce and cucumber."],
"chinese-steamed-fish-with-ginger-and-scallion": ["Cook rice.", "Set fish on a plate; spread ginger on top; steam 8–10 min (or microwave covered 4–5 min).", "Top with sliced green onions and soy.", "Heat oil until smoking; pour over the fish to sizzle."],
"blackened-tilapia-tacos": ["Coat fish in Cajun seasoning; sear in a hot oiled pan 3 min per side; flake.", "Mash avocado with sour cream and lime.", "Warm tortillas; fill with fish, shredded cabbage and crema."],
"shrimp-ceviche-tostadas": ["Poach shrimp 1 min; chill and chop.", "Toss with lime juice, diced tomato, onion, jalapeño and cilantro; chill 20 min.", "Bake oiled tortillas at 400°F 8–10 min until crisp.", "Top tostadas with ceviche and sliced avocado."],
"rice-cooker-shrimp-jambalaya": ["Rinse rice; add to cooker with broth, tomatoes, sliced sausage and Cajun seasoning.", "Run the white rice cycle.", "Stir in shrimp; close and keep warm 10 min until pink."],
"crab-rangoon-dip-pasta": ["Boil penne.", "Melt cream cheese with milk until smooth.", "Stir in shredded crab and sliced green onions.", "Toss with pasta; drizzle sweet chili sauce."],
"caribbean-coconut-shrimp-curry": ["Cook rice.", "Simmer coconut milk with curry powder, jerk seasoning and cubed sweet potato 15 min.", "Add sliced pepper and shrimp; cook 4 min.", "Serve over rice."],
};

106
app/lib/sanitize.ts Normal file
View file

@ -0,0 +1,106 @@
import type { Appliance, CookingStyle, Cuisine, ItemId, MealPlanState } from "@/app/types";
import { CATALOG_BY_ID, PANTRY_STAPLE_IDS } from "./catalog";
import { APPLIANCES, CUISINES, STYLES } from "./plan";
import { RECIPES_BY_ID } from "./recipes";
// ---------------------------------------------------------------------------
// One place that turns "anything" into a valid MealPlanState.
//
// Used by:
// - the Server Action (input from the network is untrusted)
// - the localStorage loader (old versions of the app saved older shapes)
//
// Old-shape migrations live here too, so components never see legacy data.
// ---------------------------------------------------------------------------
export const DEFAULT_STATE: MealPlanState = {
targetBudget: 75,
daysCount: 7,
mealsPerDay: 2,
householdSize: 2,
selectedAppliances: ["stovetop", "oven", "microwave"],
preferredCuisines: [],
preferredStyles: [],
avoidedItems: [],
onHandItems: PANTRY_STAPLE_IDS,
slots: {},
};
/** v1 had a single "asian" cuisine; it was split into four. */
const LEGACY_CUISINES: Record<string, Cuisine[]> = {
asian: ["chinese", "japanese", "korean", "thai"],
};
const VALID_APPLIANCES = new Set<string>(APPLIANCES.map((a) => a.id));
const VALID_CUISINES = new Set<string>(CUISINES.map((c) => c.id));
const VALID_STYLES = new Set<string>(STYLES.map((s) => s.id));
const clamp = (value: unknown, min: number, max: number, fallback: number) => {
const n = typeof value === "number" && Number.isFinite(value) ? value : fallback;
return Math.min(max, Math.max(min, n));
};
/** Keep only known string values, de-duplicated. */
const onlyValid = <T extends string>(values: unknown, isValid: (v: string) => boolean): T[] =>
Array.isArray(values) ? ([...new Set(values.filter((v): v is string => typeof v === "string" && isValid(v)))] as T[]) : [];
export function sanitizeState(input: unknown): MealPlanState {
const raw = (typeof input === "object" && input !== null ? input : {}) as Record<string, unknown>;
// Slots: "day-meal" keys pointing at real recipes only.
const slots: Record<string, string> = {};
for (const [key, recipeId] of Object.entries((raw.slots as Record<string, unknown>) ?? {})) {
if (/^[0-6]-[1-3]$/.test(key) && typeof recipeId === "string" && RECIPES_BY_ID.has(recipeId)) slots[key] = recipeId;
}
// Cuisines, with the legacy "asian" expanded.
const cuisinesIn = Array.isArray(raw.preferredCuisines) ? raw.preferredCuisines.flatMap((c) => LEGACY_CUISINES[c as string] ?? [c]) : [];
// Items. v1 had `ownsPantryStaples: boolean` instead of an on-hand list.
const isItem = (id: string) => CATALOG_BY_ID.has(id);
const avoidedItems = onlyValid<ItemId>(raw.avoidedItems, isItem);
const onHandItems = Array.isArray(raw.onHandItems)
? onlyValid<ItemId>(raw.onHandItems, isItem)
: raw.ownsPantryStaples === false
? []
: PANTRY_STAPLE_IDS;
return {
targetBudget: clamp(raw.targetBudget, 10, 1000, DEFAULT_STATE.targetBudget),
daysCount: Math.round(clamp(raw.daysCount, 1, 7, DEFAULT_STATE.daysCount)),
mealsPerDay: Math.round(clamp(raw.mealsPerDay, 1, 3, DEFAULT_STATE.mealsPerDay)),
householdSize: Math.round(clamp(raw.householdSize, 1, 6, DEFAULT_STATE.householdSize)),
selectedAppliances: Array.isArray(raw.selectedAppliances)
? onlyValid<Appliance>(raw.selectedAppliances, (v) => VALID_APPLIANCES.has(v))
: DEFAULT_STATE.selectedAppliances,
preferredCuisines: onlyValid<Cuisine>(cuisinesIn, (v) => VALID_CUISINES.has(v)),
preferredStyles: onlyValid<CookingStyle>(raw.preferredStyles, (v) => VALID_STYLES.has(v)),
avoidedItems,
// An item can't be both avoided and on hand; "avoid" wins.
onHandItems: onHandItems.filter((id) => !avoidedItems.includes(id)),
slots,
};
}
/**
* Plans saved before meal numbers were fixed counted from the first VISIBLE
* meal: with 2 meals a day, "1" meant Lunch. Today "1" is always Breakfast,
* so shift each meal up by the number of meals that were hidden in front of it.
* 2 meals/day: 1 (Lunch) → 2, 2 (Dinner) → 3
* 1 meal/day: 1 (Dinner) → 3
* Anything that lands past Dinner was already hidden and is dropped.
*/
export function migrateV1Slots(input: unknown): unknown {
if (typeof input !== "object" || input === null) return input;
const raw = input as Record<string, unknown>;
const mealsPerDay = Math.round(clamp(raw.mealsPerDay, 1, 3, DEFAULT_STATE.mealsPerDay));
const offset = 3 - mealsPerDay;
const slots: Record<string, unknown> = {};
for (const [key, recipeId] of Object.entries((raw.slots as Record<string, unknown>) ?? {})) {
const [day, meal] = key.split("-").map(Number);
const fixedMeal = meal + offset;
if (fixedMeal >= 1 && fixedMeal <= 3) slots[`${day}-${fixedMeal}`] = recipeId;
}
return { ...raw, slots };
}

54
app/lib/units.ts Normal file
View file

@ -0,0 +1,54 @@
import type { BaseUnit, RecipeUnit, StoreItem } from "@/app/types";
// ---------------------------------------------------------------------------
// Unit conversion: recipe units → the catalog item's base unit.
//
// Weight ↔ volume crossings (e.g. "1 cup rice" for a rice bag sold by weight)
// use the item's `gramsPerMl`. Anything else that doesn't line up is a data
// bug, so we throw loudly instead of guessing.
// ---------------------------------------------------------------------------
const GRAMS_PER: Partial<Record<RecipeUnit, number>> = { g: 1, oz: 28.35, lb: 453.6 };
const ML_PER: Partial<Record<RecipeUnit, number>> = { ml: 1, tsp: 4.93, tbsp: 14.79, cup: 236.6 };
export function toBaseUnit(amount: number, unit: RecipeUnit, item: StoreItem): number {
const base = item.baseUnit;
if (base === "each") {
if (unit === "each") return amount;
throw new Error(`"${item.id}" is sold by count; recipe used "${unit}"`);
}
if (base === "g") {
if (GRAMS_PER[unit] !== undefined) return amount * GRAMS_PER[unit]!;
if (ML_PER[unit] !== undefined && item.gramsPerMl) return amount * ML_PER[unit]! * item.gramsPerMl;
}
if (base === "ml") {
if (ML_PER[unit] !== undefined) return amount * ML_PER[unit]!;
}
throw new Error(`Can't convert ${unit} → ${base} for "${item.id}" (missing gramsPerMl?)`);
}
/** Pretty-print a base-unit amount using US kitchen units. */
export function formatAmount(amount: number, base: BaseUnit): string {
if (base === "each") {
const rounded = Math.round(amount * 10) / 10;
return `${rounded}`;
}
if (base === "g") {
const lb = amount / 453.6;
return lb >= 1 ? `${lb.toFixed(1)} lb` : `${(amount / 28.35).toFixed(1)} oz`;
}
// ml
const cups = amount / 236.6;
// Number(...) drops trailing zeros: 2.50 → 2.5, 2.00 → 2
if (cups >= 0.25) return `${Number(cups.toFixed(cups >= 4 ? 0 : 2))} cup`;
const tbsp = amount / 14.79;
return tbsp >= 1 ? `${tbsp.toFixed(1)} tbsp` : `${(amount / 4.93).toFixed(1)} tsp`;
}
export function formatUsd(value: number): string {
return value.toLocaleString("en-US", { style: "currency", currency: "USD" });
}

128
app/lib/usePlanState.ts Normal file
View file

@ -0,0 +1,128 @@
"use client";
import { useEffect, useReducer, useState } from "react";
import type { Appliance, CookingStyle, Cuisine, ItemId, ItemPreference, MealPlanState } from "@/app/types";
import { toggle, withoutVisibleSlots } from "./plan";
import { DEFAULT_STATE, migrateV1Slots, sanitizeState } from "./sanitize";
// ---------------------------------------------------------------------------
// All plan state lives in one reducer, persisted to localStorage.
//
// Why a reducer: a dozen controls each change one field, and the slot grid
// needs swap/replace operations. One switch statement is easier to follow
// than a dozen useStates.
// ---------------------------------------------------------------------------
// v2: meal numbers became fixed (1 = Breakfast, 2 = Lunch, 3 = Dinner).
// v1 numbered meals from the first VISIBLE one, so it's converted once on load.
const STORAGE_KEY = "budget-meal-planner:v2";
const LEGACY_STORAGE_KEY = "budget-meal-planner:v1";
export type PlanAction =
| { type: "setBudget"; value: number }
| { type: "setDays"; value: number }
| { type: "setMealsPerDay"; value: number }
| { type: "setHouseholdSize"; value: number }
| { type: "toggleAppliance"; value: Appliance }
| { type: "toggleCuisine"; value: Cuisine }
| { type: "toggleStyle"; value: CookingStyle }
| { type: "setItemPreference"; itemIds: ItemId[]; preference: ItemPreference }
| { type: "setSlot"; key: string; recipeId: string | null }
| { type: "swapSlots"; from: string; to: string }
| { type: "replaceSlots"; slots: Record<string, string> }
| { type: "clearSlots" };
/** Exported for tests. */
export function planReducer(state: MealPlanState, action: PlanAction): MealPlanState {
switch (action.type) {
case "setBudget":
return { ...state, targetBudget: action.value };
case "setDays":
return { ...state, daysCount: action.value };
case "setMealsPerDay":
// Slots don't move: meal numbers are fixed (see visibleMeals in plan.ts).
return { ...state, mealsPerDay: action.value };
case "setHouseholdSize":
return { ...state, householdSize: action.value };
case "toggleAppliance":
return { ...state, selectedAppliances: toggle(state.selectedAppliances, action.value) };
case "toggleCuisine":
return { ...state, preferredCuisines: toggle(state.preferredCuisines, action.value) };
case "toggleStyle":
return { ...state, preferredStyles: toggle(state.preferredStyles, action.value) };
case "setItemPreference": {
// An item lives in at most one list: avoided, on hand, or neither ("use").
const ids = new Set(action.itemIds);
const avoidedItems = state.avoidedItems.filter((id) => !ids.has(id));
const onHandItems = state.onHandItems.filter((id) => !ids.has(id));
if (action.preference === "avoid") avoidedItems.push(...ids);
if (action.preference === "have") onHandItems.push(...ids);
return { ...state, avoidedItems, onHandItems };
}
case "setSlot": {
const slots = { ...state.slots };
if (action.recipeId) slots[action.key] = action.recipeId;
else delete slots[action.key];
return { ...state, slots };
}
case "swapSlots": {
const slots = { ...state.slots };
const from = slots[action.from];
const to = slots[action.to];
if (to) slots[action.from] = to;
else delete slots[action.from];
if (from) slots[action.to] = from;
else delete slots[action.to];
return { ...state, slots };
}
case "replaceSlots":
return { ...state, slots: action.slots };
case "clearSlots":
// Clears what you can see; meals on hidden days/meals survive.
return { ...state, slots: withoutVisibleSlots(state) };
}
}
export type SyncStatus = "saved" | "error";
/** Read the saved plan. Only call in the browser (the planner is client-only, see page.tsx). */
function loadSavedState(): MealPlanState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) return sanitizeState(JSON.parse(raw));
const legacy = window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacy) {
window.localStorage.removeItem(LEGACY_STORAGE_KEY); // the v2 copy is written on first render
return sanitizeState(migrateV1Slots(JSON.parse(legacy)));
}
return DEFAULT_STATE;
} catch {
return DEFAULT_STATE; // corrupt JSON or blocked storage
}
}
function storageWorks(): boolean {
try {
window.localStorage.setItem(`${STORAGE_KEY}:probe`, "1");
window.localStorage.removeItem(`${STORAGE_KEY}:probe`);
return true;
} catch {
return false; // e.g. some private-browsing modes
}
}
export function usePlanState() {
// Lazy initializers run once, on first render, so the saved plan shows immediately.
const [state, dispatch] = useReducer(planReducer, undefined, loadSavedState);
const [canSave] = useState(storageWorks);
// Persist every change. (Writing to an external system is exactly what effects are for.)
useEffect(() => {
if (!canSave) return;
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}, [state, canSave]);
const syncStatus: SyncStatus = canSave ? "saved" : "error";
return { state, dispatch, syncStatus };
}

288
app/page.tsx Normal file
View file

@ -0,0 +1,288 @@
import { ArrowRight, BookHeart, Flame, ReceiptText, SlidersHorizontal } from "lucide-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 { buildGroceryList } from "./lib/grocery";
import { APPLIANCES, CUISINES } from "./lib/plan";
import { RECIPES } from "./lib/recipes";
import { formatUsd } from "./lib/units";
// ---------------------------------------------------------------------------
// 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 };
})();
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"]] },
{ theme: "Shared spice rack", blurb: "High overlap", meals: [["Mon · Dinner", "chicken-curry"], ["Tue · Lunch", "red-lentil-dal"], ["Tue · Dinner", "pad-thai"]] },
{ theme: "Weeknight seafood", blurb: "Budget catch", meals: [["Mon · Dinner", "garlic-butter-shrimp-and-rice"], ["Tue · Lunch", "tuna-chickpea-pitas"], ["Tue · Dinner", "baja-fish-tacos"]] },
{ theme: "Dorm & studio kitchen", blurb: "Microwave + rice cooker", meals: [["Mon · Breakfast", "berry-banana-smoothie"], ["Mon · Lunch", "microwave-buffalo-chicken-rice-bowls"], ["Mon · Dinner", "rice-cooker-arroz-con-pollo"]] },
{ theme: "Plant-forward week", blurb: "Meatless & filling", meals: [["Mon · Breakfast", "shakshuka"], ["Mon · Lunch", "chana-masala"], ["Mon · Dinner", "black-bean-enchiladas"]] },
{ theme: "Sunday batch prep", blurb: "Cook once, eat well", meals: [["Breakfasts", "baked-oatmeal-squares"], ["Lunches", "slow-cooker-salsa-chicken"], ["Dinners", "chicken-burrito-bowls"]] },
{ theme: "Korean pantry week", blurb: "One tub of gochujang", meals: [["Mon · Dinner", "bulgogi-beef-bowls"], ["Tue · Lunch", "kimchi-fried-rice"], ["Tue · Dinner", "dakgalbi-spicy-chicken-stir-fry"]] },
];
const PREVIEW_SETS: PreviewSet[] = PREVIEW_THEMES.map(({ theme, blurb, meals }) => {
const options = { householdSize: 2, onHandItems: PANTRY_STAPLE_IDS };
const recipe = (id: string) => {
const found = RECIPES.find((r) => r.id === id);
if (!found) throw new Error(`Landing preview: unknown recipe id "${id}"`);
return found;
};
const cart = buildGroceryList(meals.map(([, id]) => id), options);
return {
theme,
blurb,
meals: meals.map(([slot, id]) => ({ slot, title: recipe(id).title, cost: formatUsd(buildGroceryList([id], options).usedTotal) })),
total: formatUsd(cart.checkoutTotal),
sharedItems: cart.sharedItemCount,
itemCount: cart.lines.length,
};
});
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.",
},
{
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.",
},
{
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.",
},
];
const FAMILY_COUNT = RECIPES.filter((r) => r.styles.includes("caydens_corner")).length;
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>
<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>
<RotatingPreview sets={PREVIEW_SETS} />
</div>
</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>
</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>
{/* 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>
</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">
<Logo small />
<p>Estimates reflect average grocery prices as of October 2026 and vary by store and region.</p>
</div>
</footer>
</div>
);
}

12
app/plan/page.tsx Normal file
View file

@ -0,0 +1,12 @@
import type { Metadata } from "next";
import { PlannerLoader } from "@/app/components/PlannerLoader";
export const metadata: Metadata = {
title: "Planner",
};
// Server component shell so this route can export metadata; the planner itself
// is client-only (see PlannerLoader for why).
export default function PlanPage() {
return <PlannerLoader />;
}

161
app/recipes/[id]/page.tsx Normal file
View file

@ -0,0 +1,161 @@
import { ArrowLeft, BookHeart } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { Suspense, ViewTransition } from "react";
import { AddToPlanControl } from "@/app/components/AddToPlanControl";
import { RecipeMeta } from "@/app/components/RecipeDetail";
import { RecipeServings } from "@/app/components/RecipeServings";
import { SiteHeader } from "@/app/components/SiteHeader";
import { PANTRY_STAPLE_IDS } from "@/app/lib/catalog";
import { buildGroceryList } from "@/app/lib/grocery";
import { CUISINES } from "@/app/lib/plan";
import { RECIPES, RECIPES_BY_ID } from "@/app/lib/recipes";
import { formatUsd } from "@/app/lib/units";
export function generateStaticParams() {
return RECIPES.map((r) => ({ id: r.id }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const recipe = RECIPES_BY_ID.get((await params).id);
return recipe
? {
title: recipe.title,
description: recipe.description,
}
: {};
}
/**
* URL-independent App Shell renders instantly; everything reading `params`
* lives inside <Suspense> so Next 16 Partial Prefetching never blocks navigation.
*/
export default function RecipePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="min-h-dvh">
<SiteHeader active="recipes" />
<main className="mx-auto max-w-5xl px-4 py-10 sm:px-6">
<Suspense fallback={<RecipeSkeleton />}>
<RecipeContent params={params} />
</Suspense>
</main>
</div>
);
}
async function RecipeContent({ params }: { params: Promise<{ id: string }> }) {
const recipe = RECIPES_BY_ID.get((await params).id);
if (!recipe) notFound();
const cost = buildGroceryList([recipe.id], {
householdSize: recipe.servings,
onHandItems: PANTRY_STAPLE_IDS,
});
const cuisineLabel = CUISINES.find((c) => c.id === recipe.cuisine)?.label;
const isFamily = recipe.styles.includes("caydens_corner");
const related = RECIPES.filter(
(r) =>
r.id !== recipe.id &&
r.kind === recipe.kind &&
(isFamily
? r.styles.includes("caydens_corner")
: r.cuisine === recipe.cuisine && !r.styles.includes("caydens_corner")),
).slice(0, 4);
return (
<>
<Link
href={isFamily ? "/caydens-corner" : "/recipes"}
className="inline-flex items-center gap-1.5 text-sm font-medium text-ink-muted transition-colors hover:text-brand-300"
>
<ArrowLeft className="size-4" aria-hidden />
{isFamily ? "Cayden's Corner" : "All recipes"}
</Link>
{isFamily && (
<p className="mt-4 inline-flex items-center gap-1.5 font-mono text-xs font-semibold tracking-wider text-brand-300 uppercase">
<BookHeart className="size-3.5" strokeWidth={2} aria-hidden />
Heirloom Family Recipe
</p>
)}
<ViewTransition name={`recipe-title-${recipe.id}`}>
<h1 className="mt-3 font-serif text-3xl font-semibold tracking-tight text-balance sm:text-4xl lg:text-5xl">
{recipe.title}
</h1>
</ViewTransition>
<p className="mt-3 max-w-2xl text-lg leading-relaxed text-ink-muted">{recipe.description}</p>
<div className="mt-4">
<RecipeMeta recipe={recipe} />
</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">
<dl className="flex flex-wrap items-center gap-6 sm:gap-10">
<div>
<dt className="text-xs text-ink-faint">Cost eaten / serving</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold text-brand-300 tabular-nums">
{formatUsd(cost.usedTotal / recipe.servings)}
</dd>
</div>
<div className="border-l border-line pl-6 sm:pl-10">
<dt className="text-xs text-ink-faint">Full packages new</dt>
<dd className="mt-0.5 font-mono text-lg font-semibold text-ink tabular-nums">
{formatUsd(cost.checkoutTotal)}
</dd>
</div>
</dl>
<AddToPlanControl recipeId={recipe.id} recipeTitle={recipe.title} />
</div>
<div className="mt-8 rounded-xl border border-line bg-surface p-5 sm:p-8">
<RecipeServings recipe={recipe} />
</div>
{related.length > 0 && (
<section className="mt-12 border-t border-line pt-8">
<h2 className="font-serif text-xl font-semibold">
More {isFamily ? "from Cayden's Corner" : cuisineLabel}
</h2>
<ul className="mt-4 grid gap-3.5 sm:grid-cols-2">
{related.map((r) => (
<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"
>
<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>
</Link>
</li>
))}
</ul>
</section>
)}
</>
);
}
function RecipeSkeleton() {
return (
<div className="animate-pulse space-y-4" aria-busy="true" aria-label="Loading recipe">
<div className="h-4 w-28 rounded bg-raised" />
<div className="h-11 w-2/3 rounded bg-raised" />
<div className="h-5 w-1/2 rounded bg-raised" />
<div className="mt-6 h-20 rounded-xl bg-raised" />
<div className="mt-8 h-96 rounded-xl bg-raised" />
</div>
);
}

45
app/recipes/page.tsx Normal file
View file

@ -0,0 +1,45 @@
import type { Metadata } from "next";
import { RecipeBrowser, type RecipeSummary } from "@/app/components/RecipeBrowser";
import { SiteHeader } from "@/app/components/SiteHeader";
import { CATALOG_BY_ID } from "@/app/lib/catalog";
import { CUISINES } from "@/app/lib/plan";
import { RECIPES } from "@/app/lib/recipes";
export const metadata: Metadata = {
title: "Recipes",
description: `Browse all ${RECIPES.length} budget-friendly recipes across ${CUISINES.length} cuisines.`,
};
const SUMMARIES: RecipeSummary[] = RECIPES.map((r) => ({
id: r.id,
title: r.title,
description: r.description,
cuisine: r.cuisine,
kind: r.kind,
styles: r.styles,
appliances: r.requiredAppliances,
prepTime: r.prepTime,
ingredientText: r.ingredients.map((i) => CATALOG_BY_ID.get(i.itemId)?.name ?? i.itemId).join(" ").toLowerCase(),
}));
export default function RecipesPage() {
return (
<div className="min-h-dvh">
<SiteHeader active="recipes" />
<main className="mx-auto max-w-7xl px-4 py-10 sm:px-6">
<div className="border-b border-line pb-6">
<p className="font-mono text-xs tracking-widest text-brand-300 uppercase">Complete Index</p>
<h1 className="mt-1 font-serif text-3xl font-semibold tracking-tight sm:text-4xl">Recipe library</h1>
<p className="mt-2 max-w-2xl text-ink-muted">
Every dish the planner draws from: {RECIPES.length} tested recipes across {CUISINES.length} cuisines. Search by
ingredient (try &ldquo;chickpeas&rdquo; or &ldquo;gochujang&rdquo;) or filter strictly to the appliances in your kitchen.
</p>
</div>
<div className="mt-8">
<RecipeBrowser recipes={SUMMARIES} />
</div>
</main>
</div>
);
}

181
app/types/index.ts Normal file
View file

@ -0,0 +1,181 @@
// ---------------------------------------------------------------------------
// Shared types for the whole app (client + server).
// Keep this file dependency-free so it can be imported anywhere.
// ---------------------------------------------------------------------------
export type Appliance = "stovetop" | "oven" | "air_fryer" | "slow_cooker" | "microwave" | "blender" | "rice_cooker";
export type Cuisine =
| "american"
| "southern"
| "mexican"
| "caribbean"
| "italian"
| "mediterranean"
| "middle_eastern"
| "indian"
| "chinese"
| "japanese"
| "korean"
| "thai";
/** "caydens_corner" marks the family recipes collection. */
export type CookingStyle = "comfort" | "quick" | "healthy" | "meal_prep" | "vegetarian" | "seafood" | "caydens_corner";
/**
* Breakfast slots only get breakfast recipes; lunch/dinner slots get mains.
* Sides and desserts live in the library and can be added to a slot by hand,
* but auto-fill never picks them.
*/
export type MealKind = "breakfast" | "main" | "side" | "dessert";
// --- Units -----------------------------------------------------------------
/** Units recipes are written in (what a human would type). */
export type RecipeUnit = "g" | "oz" | "lb" | "ml" | "tsp" | "tbsp" | "cup" | "each";
/** Units the store catalog is priced in. Every recipe unit converts to one of these. */
export type BaseUnit = "g" | "ml" | "each";
// --- Store catalog ---------------------------------------------------------
export type ItemId = string;
export type Aisle = "Meat & Eggs" | "Seafood" | "Dairy" | "Produce" | "Frozen" | "Pantry" | "International" | "Spices & Sauces" | "Bakery";
/** One purchasable product, e.g. "Long Grain White Rice, 2 lb bag". */
export interface StoreItem {
id: ItemId;
name: string;
aisle: Aisle;
/** How much is in one package, in `baseUnit`. */
packageSize: number;
baseUnit: BaseUnit;
/** Human label for one package, e.g. "2 lb bag". */
packageLabel: string;
/** Price of one package, USD. */
price: number;
/**
* Lets weight-priced items be measured by volume in recipes
* (e.g. rice: 1 cup ≈ 185 g → 0.78 g/ml). Only needed for `g` items.
*/
gramsPerMl?: number;
/** Salt, oil, etc. Marked "have it" by default for new users. */
pantryStaple?: boolean;
}
// --- Recipes ---------------------------------------------------------------
export interface RecipeIngredient {
/** Points at a StoreItem. Cost is derived from the catalog, never hard-coded per recipe. */
itemId: ItemId;
amount: number;
unit: RecipeUnit;
/** Optional display note, e.g. "diced". */
note?: string;
}
export interface Recipe {
id: string;
title: string;
description: string;
cuisine: Cuisine;
styles: CookingStyle[];
kind: MealKind;
/** Minutes, hands-on + cook. */
prepTime: number;
/** ALL of these are needed. An empty array means no-cook. */
requiredAppliances: Appliance[];
/** How many people one batch feeds. Ingredients scale to household size. */
servings: number;
ingredients: RecipeIngredient[];
/** Cooking instructions, in order. Kept in recipes/steps/ and joined in by recipes/index.ts. */
steps: string[];
/** Optional cook's tip shown under the steps. */
tip?: string;
}
/** A recipe as written in the data files, before its steps are attached. */
export type RecipeData = Omit<Recipe, "steps">;
// --- Plan state --------------------------------------------------------------
export interface MealSlot {
dayIndex: number; // 0-6
mealNumber: number; // 1-3
recipeId: string | null;
}
export interface MealPlanState {
targetBudget: number;
daysCount: number; // 1-7
mealsPerDay: number; // 1-3
householdSize: number; // 1-6
selectedAppliances: Appliance[];
/** Empty = any cuisine. */
preferredCuisines: Cuisine[];
/** Empty = any style. */
preferredStyles: CookingStyle[];
/** Never plan recipes that use these store items. */
avoidedItems: ItemId[];
/** Already in the kitchen: listed on the grocery list but cost $0. */
onHandItems: ItemId[];
/**
* Keyed "<day>-<meal>" (day 0 = Mon; meal 1/2/3 = Breakfast/Lunch/Dinner) → recipe id.
* Only filled slots are stored. Slots outside daysCount/mealsPerDay are kept but hidden.
*/
slots: Record<string, string>;
}
// --- Grocery output ----------------------------------------------------------
export interface GroceryLine {
item: StoreItem;
/** Total amount all recipes need, in the item's base unit. */
neededAmount: number;
/** Whole packages to buy. */
packages: number;
/** What you pay at checkout for this line. */
checkoutCost: number;
/** Value of what actually gets eaten this week. */
usedCost: number;
/** Left over after the week, in base unit (goes to your pantry). */
leftoverAmount: number;
/** User marked this item as already in their kitchen. */
onHand: boolean;
/** Recipe titles that use this item. Length > 1 means ingredient overlap. */
usedBy: string[];
}
export interface GrocerySummary {
lines: GroceryLine[];
/** Sum of checkout costs (excludes on-hand lines). */
checkoutTotal: number;
/** Value of food actually eaten this plan. */
usedTotal: number;
/** checkoutTotal - usedTotal: what you carry into next week. */
carryoverValue: number;
/** How many store items are shared by 2+ recipes. */
sharedItemCount: number;
}
/** Per-ingredient setting in the Ingredients panel. */
export type ItemPreference = "avoid" | "use" | "have";
// --- Server action contract -------------------------------------------------
export type FillMode = "empty" | "all";
export interface GeneratePlanInput {
state: MealPlanState;
fillMode: FillMode;
/** Changing the seed gives a different (but still cost-optimized) plan. */
seed: number;
}
export interface GeneratePlanResult {
slots: Record<string, string>;
summary: GrocerySummary;
/** Human-readable notes, e.g. when filters had to be relaxed. */
notes: string[];
}

927
bun.lock Normal file
View file

@ -0,0 +1,927 @@
{
"lockfileVersion": 2,
"configVersion": 0,
"workspaces": {
"": {
"name": "plan-my-meals",
"dependencies": {
"lucide-react": "^1.66.0",
"next": "16.4.3",
"react": "19.3.0",
"react-dom": "19.3.0",
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
"@types/bun": "^1.4.4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.3",
"tailwindcss": "^4",
"typescript": "^5",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.3.0", "", {}, "sha512-U4+70Pc5ZS9osnCBCE5Jha/ciHM+Yp+CNMNC/7HvYbNRk1Ldd+f7qO65W5qfhu/TCv+/ozljlXXe9Nj8419DMA=="],
"@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
"@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
"@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="],
"@babel/generator": ["@babel/generator@7.29.12", "", { "dependencies": { "@babel/parser": "^7.29.12", "@babel/types": "^7.29.11", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA=="],
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
"@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.11", "", {}, "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
"@babel/helpers": ["@babel/helpers@7.29.10", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.8" } }, "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg=="],
"@babel/parser": ["@babel/parser@7.29.12", "", { "dependencies": { "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg=="],
"@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
"@babel/traverse": ["@babel/traverse@7.29.12", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.12", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.12", "@babel/template": "^7.29.7", "@babel/types": "^7.29.11", "debug": "^4.3.1" } }, "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew=="],
"@babel/types": ["@babel/types@7.29.11", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.11", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ=="],
"@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.2", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.5" } }, "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.7", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.3.2", "minimatch": "^3.1.5", "strip-json-comments": "^3.1.1" } }, "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw=="],
"@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.4" }, "os": "darwin", "cpu": "x64" }, "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw=="],
"@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "os": "freebsd" }, "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.4", "", { "os": "linux", "cpu": "arm" }, "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ=="],
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.4", "", { "os": "linux", "cpu": "none" }, "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.4" }, "os": "linux", "cpu": "arm" }, "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA=="],
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.4" }, "os": "linux", "cpu": "none" }, "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.4" }, "os": "linux", "cpu": "s390x" }, "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" }, "os": "linux", "cpu": "arm64" }, "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.4" }, "os": "linux", "cpu": "x64" }, "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.5", "", { "dependencies": { "@emnapi/runtime": "^1.11.3" } }, "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ=="],
"@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.5", "", { "dependencies": { "@img/sharp-wasm32": "0.35.5" }, "cpu": "none" }, "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.5", "", { "os": "win32", "cpu": "x64" }, "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw=="],
"@next/env": ["@next/env@16.4.3", "", {}, "sha512-a7tz1QCcWWkwC/rPJcPMJk/UNRu9CFhDd3OHYE5hddbJrs8BqRTalxI6GlKCWEGzRLHN+S/B7a4PAHCtm25aBQ=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@16.4.3", "", { "dependencies": { "@eslint-community/eslint-utils": "4.9.1", "fast-glob": "3.3.1" } }, "sha512-oxuC06wt/71h6ACYCV9GbKOfnf5a7VlRe/p80YHTut15TNgDEmynDnaq8t/xhy5rlvk7dhItIPs6H3rvIM4kyw=="],
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4yq5EXBt+VDtAZwTyfijaFp5D2C5oz3Ikw5a2kiX2K9nw72vrZX2q5UuAlkPtUMO1tQz6JTKpaKWjS7OOefTDQ=="],
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-1Rvkn/LUhnEF8WxnRkOrqGhbwXui1bkWFxONKIbN+r7C+9KHDSZ+iUoj4CzymVMWUyZIyx9szcb9+kbKhD500A=="],
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-dCKpXErGg3abUM6t5JTJV/ottVBTStJB5lRJTbgdUdQaQ+jNHiK56Hsc0YAEg/rinSh42hYG3HWiYmEMdilnxg=="],
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-WwYbhWkhYBvBtib1K4W0kL8MW2snBD2A5b3SN4vPpqalZbZs5+bQ1yzAdepVoOqrFfEW3dmREy4Zs1MrWcrFbw=="],
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-QymWvfUwDEC5/lpJOsxX5VAbyoR2D13+2Jsjz4be7gT4LPqEgdpkeLIR/HFfCLgKzoMhpJbp6I/JnogyNZHcaA=="],
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Vmjd9D7DpA4Vv3cUlr82QWgJD/BQZmns801vtC5VWHg6jCIA4a5vMKpQnfrM2pyCrp2eV0ENdUg4PlN3bAc2Yw=="],
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-lG1pPdPLk7gTyGb00n5JIMLdBJgjb+UEhSdZvFbhUMg0DyC6z4B7RnpSKF9VCd4imkLObVbXOFA0kFCpw7yFow=="],
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NmQC9RkJdnPXRaMxtkCzobB3teVjqF33oSOnwDfi+XYGWWYpt+xlGK3mtktSKLbdjwaWbJu2OkkMiRFGUUncKg=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="],
"@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="],
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
"@tailwindcss/turbopack": ["@tailwindcss/turbopack@4.3.3", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" } }, "sha512-FgL4wRgItNdXcDjcgYUSd59gNsWqNVK4GotjZimZMmt7OOIkRNqH2QEUcRXLdBDXvwTsi5rkYxDxzfrFUkmObQ=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
"@types/estree": ["@types/estree@1.0.10", "", {}, "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/json5": ["@types/json5@0.0.29", "", {}, "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ=="],
"@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="],
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.72.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.72.1", "@typescript-eslint/type-utils": "8.72.1", "@typescript-eslint/utils": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.72.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5CokTWXpmbylfFabsbvHC0CAw3XLaGnoa+nFJX5FR592h/LoZsENUE6p/rg5xvkKrFEXz6pYmgI29AgXc2N3BQ=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.72.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.72.1", "@typescript-eslint/types": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ZGwZP4OCfyA0AQTpEAMWz+rtX01DRhQWW5hB0ucKDEIX5U7jdmPSaMx94rQfaPAQzIYz2BimOgKOJtjfpRFQ9g=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.72.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.72.1", "@typescript-eslint/types": "^8.72.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-fAuVMkwdO/huI8nyo/WKnNKf6Gag93S56Rh3Ts5mGcavwSdaN8tRg1gCeIGX8PF+nzp1BjGfO4gHhGzW1J6jTg=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.72.1", "", { "dependencies": { "@typescript-eslint/types": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1" } }, "sha512-lC/t6eMCZZQAAKZEYGL6RDVSuFehpdmMT6h+nJIjIa6oylN/PwDgDVtEIbB7O9xFfZYtZAYLoLlDNWKJhtF+uQ=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.72.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-e4D5R5vpeAEPBxT2BAzsFQL3sbwyeeeo5+aczgNSRK1V734jE7pTqaGFU+fXapWEQwnZbpFaTn32+Z8DwS6OTg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.72.1", "", { "dependencies": { "@typescript-eslint/types": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1", "@typescript-eslint/utils": "8.72.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-iY2wlmyrXJmx4yxUGP3aYCFF3cA/6ShddOKmLU2dx7LnEykbAM8WThlxZEfd1NddqceF6eJmZUayxx9oKIYNlg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.72.1", "", {}, "sha512-LmE5IhQet1BWOWEXqBrVtBPsMAOQ6Nzk35d+WLh3mk4EUF33sqlfxjio1MdYeSvKSG5skactKKuixBIKXhL4Rw=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.72.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.72.1", "@typescript-eslint/tsconfig-utils": "8.72.1", "@typescript-eslint/types": "8.72.1", "@typescript-eslint/visitor-keys": "8.72.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DAlUoUV93hIvrtIQLwCbmZQYVZwOMhiHJrOVUUIWicFZaIYjTRqlY3I/NZevfkLmSqkNHRu2qYDcq9lrn16oOQ=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.72.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.72.1", "@typescript-eslint/types": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-5v5liypurJBFHCBNqh/RjsymAD+VWcE0djuGY/BUqp5fC3Qn+luWDEksjuTtWjPn6i5zQlUtkZCRG/CYG0yCkA=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.72.1", "", { "dependencies": { "@typescript-eslint/types": "8.72.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-SB8xqipSzZHUePCCLNeYxqnxq83X61/qzNhjQGbTfjxGZIeOEP7tBgWYX05Tcd6CgE02WdTJnh5b8kMZJ/+bnQ=="],
"@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.12.2", "", { "os": "android", "cpu": "arm" }, "sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w=="],
"@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.12.2", "", { "os": "android", "cpu": "arm64" }, "sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ=="],
"@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.12.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w=="],
"@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.12.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA=="],
"@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.12.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg=="],
"@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A=="],
"@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.12.2", "", { "os": "linux", "cpu": "arm" }, "sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g=="],
"@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg=="],
"@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.12.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA=="],
"@unrs/resolver-binding-linux-loong64-gnu": ["@unrs/resolver-binding-linux-loong64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q=="],
"@unrs/resolver-binding-linux-loong64-musl": ["@unrs/resolver-binding-linux-loong64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew=="],
"@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.12.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg=="],
"@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A=="],
"@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.12.2", "", { "os": "linux", "cpu": "none" }, "sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w=="],
"@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.12.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw=="],
"@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ=="],
"@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.12.2", "", { "os": "linux", "cpu": "x64" }, "sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A=="],
"@unrs/resolver-binding-openharmony-arm64": ["@unrs/resolver-binding-openharmony-arm64@1.12.2", "", { "os": "none", "cpu": "arm64" }, "sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ=="],
"@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.12.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A=="],
"@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.12.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g=="],
"@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.12.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g=="],
"@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.12.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA=="],
"acorn": ["acorn@8.19.0", "", { "bin": "bin/acorn" }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
"array-includes": ["array-includes@3.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "es-shim-unscopables": "^1.1.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-VXY5eFRarnXcYxwBjJzPmEhH55+rmP79/+ueDhi0F+TuqfHCItagIHqxeUZrmgrOPa31QTh9H85DjX3FfJ0FTg=="],
"array.prototype.findlast": ["array.prototype.findlast@1.2.5", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ=="],
"array.prototype.findlastindex": ["array.prototype.findlastindex@1.2.6", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-shim-unscopables": "^1.1.0" } }, "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ=="],
"array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="],
"array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="],
"array.prototype.tosorted": ["array.prototype.tosorted@1.1.4", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3", "es-errors": "^1.3.0", "es-shim-unscopables": "^1.0.2" } }, "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA=="],
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"axe-core": ["axe-core@4.14.0", "", {}, "sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ=="],
"axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.34", "", { "bin": "dist/cli.cjs" }, "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA=="],
"brace-expansion": ["brace-expansion@1.1.21", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.29.5", "", { "dependencies": { "baseline-browser-mapping": "^2.11.33", "caniuse-lite": "^1.0.30001819", "electron-to-chromium": "^1.5.464", "node-releases": "^2.0.60", "update-browserslist-db": "^1.3.4" }, "bin": "cli.js" }, "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ=="],
"bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
"call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001820", "", {}, "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
"data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="],
"define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"doctrine": ["doctrine@2.1.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.472", "", {}, "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="],
"es-abstract": ["es-abstract@1.24.2", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg=="],
"es-abstract-get": ["es-abstract-get@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "is-callable": "^1.2.7", "object-inspect": "^1.13.4" } }, "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-iterator-helpers": ["es-iterator-helpers@1.4.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "iterator.prototype": "^1.1.5", "math-intrinsics": "^1.1.0" } }, "sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q=="],
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="],
"es-to-primitive": ["es-to-primitive@1.3.4", "", { "dependencies": { "es-abstract-get": "^1.0.0", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "is-callable": "^1.2.7", "is-date-object": "^1.1.0", "is-symbol": "^1.1.1" } }, "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.5", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.2", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.6", "@eslint/js": "9.39.5", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": "bin/eslint.js" }, "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw=="],
"eslint-config-next": ["eslint-config-next@16.4.3", "", { "dependencies": { "@next/eslint-plugin-next": "16.4.3", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsx-a11y": "^6.10.0", "eslint-plugin-react": "^7.37.0", "eslint-plugin-react-hooks": "^7.1.0", "globals": "16.4.0", "typescript-eslint": "^8.56.0" }, "peerDependencies": { "eslint": ">=9.0.0", "typescript": ">=3.3.1" }, "optionalPeers": ["typescript"] }, "sha512-j9IqBma6abp800frdUURaizYMgXOK+jNGyq6CsYcM9AxpSJs5D/TgMNGeAmRUilZvA7g9V1Tr71gNg9/SVTuMQ=="],
"eslint-import-resolver-node": ["eslint-import-resolver-node@0.3.10", "", { "dependencies": { "debug": "^3.2.7", "is-core-module": "^2.16.1", "resolve": "^2.0.0-next.6" } }, "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ=="],
"eslint-import-resolver-typescript": ["eslint-import-resolver-typescript@3.10.1", "", { "dependencies": { "@nolyfill/is-core-module": "1.0.39", "debug": "^4.4.0", "get-tsconfig": "^4.10.0", "is-bun-module": "^2.0.0", "stable-hash": "^0.0.5", "tinyglobby": "^0.2.13", "unrs-resolver": "^1.6.2" }, "peerDependencies": { "eslint": "*", "eslint-plugin-import": "*", "eslint-plugin-import-x": "*" }, "optionalPeers": ["eslint-plugin-import", "eslint-plugin-import-x"] }, "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ=="],
"eslint-module-utils": ["eslint-module-utils@2.14.0", "", { "dependencies": { "debug": "^3.2.7" } }, "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig=="],
"eslint-plugin-import": ["eslint-plugin-import@2.32.0", "", { "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", "array.prototype.findlastindex": "^1.2.6", "array.prototype.flat": "^1.3.3", "array.prototype.flatmap": "^1.3.3", "debug": "^3.2.7", "doctrine": "^2.1.0", "eslint-import-resolver-node": "^0.3.9", "eslint-module-utils": "^2.12.1", "hasown": "^2.0.2", "is-core-module": "^2.16.1", "is-glob": "^4.0.3", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "object.groupby": "^1.0.3", "object.values": "^1.2.1", "semver": "^6.3.1", "string.prototype.trimend": "^1.0.9", "tsconfig-paths": "^3.15.0" }, "peerDependencies": { "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA=="],
"eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="],
"eslint-plugin-react": ["eslint-plugin-react@7.37.5", "", { "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", "array.prototype.flatmap": "^1.3.3", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", "es-iterator-helpers": "^1.2.1", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", "object.entries": "^1.1.9", "object.fromentries": "^2.0.8", "object.values": "^1.2.1", "prop-types": "^15.8.1", "resolve": "^2.0.0-next.5", "semver": "^6.3.1", "string.prototype.matchall": "^4.0.12", "string.prototype.repeat": "^1.0.0" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA=="],
"eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="],
"for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"function.prototype.name": ["function.prototype.name@1.2.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2", "hasown": "^2.0.4", "is-callable": "^1.2.7", "is-document.all": "^1.0.0" } }, "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew=="],
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"get-tsconfig": ["get-tsconfig@4.14.3", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.4.0", "", {}, "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw=="],
"globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="],
"has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
"hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="],
"is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="],
"is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="],
"is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="],
"is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="],
"is-bun-module": ["is-bun-module@2.0.0", "", { "dependencies": { "semver": "^7.7.1" } }, "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ=="],
"is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="],
"is-core-module": ["is-core-module@2.17.0", "", { "dependencies": { "hasown": "^2.0.4" } }, "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA=="],
"is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="],
"is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="],
"is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="],
"is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
"is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="],
"is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="],
"is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="],
"is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="],
"is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="],
"is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="],
"is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="],
"is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="],
"is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="],
"is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="],
"isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="],
"jiti": ["jiti@2.7.0", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": "bin/js-yaml.js" }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "^1.2.0" }, "bin": "lib/cli.js" }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="],
"jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="],
"language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": "cli.js" }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"lucide-react": ["lucide-react@1.66.0", "", { "peerDependencies": { "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-8zjbi/Us+ETXqfBwmXF/FHrWGIqOdE6zT1yUdRJLQVoptomSkB0zdFWVIM667t9K8djWDaXdkBkqi2qVTuDNiw=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.20", "", { "bin": "bin/nanoid.cjs" }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
"napi-postinstall": ["napi-postinstall@0.3.7", "", { "bin": { "napi-patch": "lib/patch.js", "napi-postinstall": "lib/cli.js" } }, "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"next": ["next@16.4.3", "", { "dependencies": { "@next/env": "16.4.3", "@swc/helpers": "0.5.23", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.4.3", "@next/swc-darwin-x64": "16.4.3", "@next/swc-linux-arm64-gnu": "16.4.3", "@next/swc-linux-arm64-musl": "16.4.3", "@next/swc-linux-x64-gnu": "16.4.3", "@next/swc-linux-x64-musl": "16.4.3", "@next/swc-win32-arm64-msvc": "16.4.3", "@next/swc-win32-x64-msvc": "16.4.3", "sharp": "^0.35.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": "dist/bin/next" }, "sha512-OqxKTGi9u80FydydsMNYNjnuje9Au+kvHlt2GZ//ftdpDHm1dxKqAaENoOOTX5wKzn+9vaKUNIfZeyZUuLhCnA=="],
"node-exports-info": ["node-exports-info@1.6.2", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-kXs9Go0cah0qHVV2v389IXQLdLCeE1xfFtjOAF+iobu0OIoG1pje8At2vMHyaPMiPMnG/LWP50twML21eMcAag=="],
"node-releases": ["node-releases@2.0.63", "", {}, "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="],
"object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="],
"object.entries": ["object.entries@1.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.1" } }, "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw=="],
"object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="],
"object.groupby": ["object.groupby@1.0.3", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2" } }, "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ=="],
"object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"own-keys": ["own-keys@1.0.2", "", { "dependencies": { "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
"reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="],
"resolve": ["resolve@2.0.0-next.7", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.2", "node-exports-info": "^1.6.0", "object-keys": "^1.1.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": "bin/resolve" }, "sha512-tqt+NBWwyaMgw3zDsnygx4CByWjQEJHOPMdslYhppaQSJUtL/D4JO9CcBBlhPoI8lz9oJIDXkwXfhF4aWqP8xQ=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-array-concat": ["safe-array-concat@1.1.4", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "get-intrinsic": "^1.3.0", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg=="],
"safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="],
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
"semver": ["semver@6.3.1", "", { "bin": "bin/semver.js" }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
"set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="],
"sharp": ["sharp@0.35.5", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.5" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.5", "@img/sharp-darwin-x64": "0.35.5", "@img/sharp-freebsd-wasm32": "0.35.5", "@img/sharp-libvips-darwin-arm64": "1.3.4", "@img/sharp-libvips-darwin-x64": "1.3.4", "@img/sharp-libvips-linux-arm": "1.3.4", "@img/sharp-libvips-linux-arm64": "1.3.4", "@img/sharp-libvips-linux-ppc64": "1.3.4", "@img/sharp-libvips-linux-riscv64": "1.3.4", "@img/sharp-libvips-linux-s390x": "1.3.4", "@img/sharp-libvips-linux-x64": "1.3.4", "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", "@img/sharp-libvips-linuxmusl-x64": "1.3.4", "@img/sharp-linux-arm": "0.35.5", "@img/sharp-linux-arm64": "0.35.5", "@img/sharp-linux-ppc64": "0.35.5", "@img/sharp-linux-riscv64": "0.35.5", "@img/sharp-linux-s390x": "0.35.5", "@img/sharp-linux-x64": "0.35.5", "@img/sharp-linuxmusl-arm64": "0.35.5", "@img/sharp-linuxmusl-x64": "0.35.5", "@img/sharp-webcontainers-wasm32": "0.35.5", "@img/sharp-win32-arm64": "0.35.5", "@img/sharp-win32-ia32": "0.35.5", "@img/sharp-win32-x64": "0.35.5" } }, "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
"stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
"string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="],
"string.prototype.matchall": ["string.prototype.matchall@4.1.0", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.2", "get-intrinsic": "^1.3.0", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", "regexp.prototype.flags": "^1.5.4", "set-function-name": "^2.0.2", "side-channel": "^1.1.1" } }, "sha512-tHNHTxInrYLCga9O9YGxWA3G9/nnzQw8UGAyqGx3Ar1pSTTzIuM4woFSq4SowkXCjJIwq5sIiQvEfRI9tCH1qQ=="],
"string.prototype.repeat": ["string.prototype.repeat@1.0.0", "", { "dependencies": { "define-properties": "^1.1.3", "es-abstract": "^1.17.5" } }, "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w=="],
"string.prototype.trim": ["string.prototype.trim@1.2.11", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.2", "es-object-atoms": "^1.1.2", "has-property-descriptors": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-PwvK7BU+CMTJGYQCTZb5RWXIML92lftJLhQz1tBzgKiqGxJaMlBAa48POXaNAC2s4y8jr3EFqrkF9+44neS46w=="],
"string.prototype.trimend": ["string.prototype.trimend@1.0.10", "", { "dependencies": { "call-bind": "^1.0.9", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-object-atoms": "^1.1.2" } }, "sha512-2+3aDAOmPTmuFwjDnmJG2ctEkQKVki7vOSqaxkv42Mowj1V6PnvuwFCRrR5lChUux1TBskPjfkeTOhqczDMxTw=="],
"string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="],
"strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"tsconfig-paths": ["tsconfig-paths@3.15.0", "", { "dependencies": { "@types/json5": "^0.0.29", "json5": "^1.0.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg=="],
"tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="],
"typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="],
"typed-array-byte-offset": ["typed-array-byte-offset@1.0.5", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.10" } }, "sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ=="],
"typed-array-length": ["typed-array-length@1.0.8", "", { "dependencies": { "call-bind": "^1.0.9", "for-each": "^0.3.5", "gopd": "^1.2.0", "is-typed-array": "^1.1.15", "possible-typed-array-names": "^1.1.0", "reflect.getprototypeof": "^1.0.10" } }, "sha512-phPGCwqr2+Qo0fwniCE8e4pKnGu/yFb5nD5Y8bf0EEeiI5GklnACYA9GFy/DrAeRrKHXvHn+1SUsOWgJp6RO+g=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.72.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.72.1", "@typescript-eslint/parser": "8.72.1", "@typescript-eslint/typescript-estree": "8.72.1", "@typescript-eslint/utils": "8.72.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3Li9FfPmQp45nxEtFD1ZCrYFI3cYK5kpyNtmm1H7lMagm8u9sy6PTjy3ytwQZs7KNukOyKFHAZ6z6GjYFeaeXw=="],
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unrs-resolver": ["unrs-resolver@1.12.2", "", { "dependencies": { "napi-postinstall": "^0.3.4" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.12.2", "@unrs/resolver-binding-android-arm64": "1.12.2", "@unrs/resolver-binding-darwin-arm64": "1.12.2", "@unrs/resolver-binding-darwin-x64": "1.12.2", "@unrs/resolver-binding-freebsd-x64": "1.12.2", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.12.2", "@unrs/resolver-binding-linux-arm-musleabihf": "1.12.2", "@unrs/resolver-binding-linux-arm64-gnu": "1.12.2", "@unrs/resolver-binding-linux-arm64-musl": "1.12.2", "@unrs/resolver-binding-linux-loong64-gnu": "1.12.2", "@unrs/resolver-binding-linux-loong64-musl": "1.12.2", "@unrs/resolver-binding-linux-ppc64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-gnu": "1.12.2", "@unrs/resolver-binding-linux-riscv64-musl": "1.12.2", "@unrs/resolver-binding-linux-s390x-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-gnu": "1.12.2", "@unrs/resolver-binding-linux-x64-musl": "1.12.2", "@unrs/resolver-binding-openharmony-arm64": "1.12.2", "@unrs/resolver-binding-wasm32-wasi": "1.12.2", "@unrs/resolver-binding-win32-arm64-msvc": "1.12.2", "@unrs/resolver-binding-win32-ia32-msvc": "1.12.2", "@unrs/resolver-binding-win32-x64-msvc": "1.12.2" }, "hasInstallScript": true }, "sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ=="],
"update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": "cli.js" }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
"which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="],
"which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="],
"which-typed-array": ["which-typed-array@1.1.24", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
"@babel/core/json5": ["json5@2.2.3", "", { "bin": "lib/cli.js" }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@next/eslint-plugin-next/@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" }, "bundled": true }, "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.14", "", {}, "sha512-wCBiZ77u1tuWde6G/nfOtLaCTYcFDU5yCzARzK10y+rLhzYoL7a7yEemcopcc2Mzow7gZOIjnV2ym1NYmdiJOQ=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="],
"@typescript-eslint/typescript-estree/semver": ["semver@7.8.5", "", { "bin": "bin/semver.js" }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"eslint-plugin-import/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"is-bun-module/semver": ["semver@7.8.5", "", { "bin": "bin/semver.js" }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
"sharp/semver": ["semver@7.8.5", "", { "bin": "bin/semver.js" }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
"@next/eslint-plugin-next/@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.9.1", "", {}, "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
}
}

18
eslint.config.mjs Normal file
View file

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

17
next.config.ts Normal file
View file

@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
turbopack: {
rules: {
"*.css": {
loaders: ["@tailwindcss/turbopack"],
as: "*.css",
},
},
},
};
export default nextConfig;

6774
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

29
package.json Normal file
View file

@ -0,0 +1,29 @@
{
"name": "plan-my-meals",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "bun test"
},
"dependencies": {
"lucide-react": "^1.66.0",
"next": "16.4.3",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@tailwindcss/turbopack": "^4",
"@types/bun": "^1.4.4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.4.3",
"tailwindcss": "^4",
"typescript": "^5"
}
}

34
tsconfig.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}