This commit is contained in:
2026-04-28 19:06:37 -05:00
parent 31d0769d33
commit 4831cc7255
2 changed files with 551 additions and 0 deletions

View File

@@ -0,0 +1,200 @@
# Recipe Generator — Engineering Notes
> Handoff doc covering technical decisions, design choices, and bug fixes made during initial development. Last updated: April 28, 2026.
---
## What It Is
A single-page HTML app that takes user-supplied ingredients, a food goal, dietary restrictions, and preferences, then calls the Anthropic API directly from the browser to generate a structured, chef-quality recipe with precise step-by-step instructions.
No backend. No build step. One `.html` file.
---
## Tech Stack
| Layer | Choice | Reason |
|---|---|---|
| Runtime | Vanilla HTML/CSS/JS | No framework needed; single file, zero dependencies |
| Fonts | Google Fonts (Lora + DM Sans) | Lora for editorial/recipe headings; DM Sans for UI chrome |
| AI | Anthropic API (`claude-sonnet-4-20250514`) | Direct browser fetch with `anthropic-dangerous-direct-browser-access` header |
| Output format | JSON (structured) | Predictable, renderable, extensible |
---
## Inputs Collected
| Field | Type | Notes |
|---|---|---|
| Ingredients on hand | Tag input (multi-value) | Comma or Enter to add; Backspace to remove last |
| Food goal | Free text | e.g. "a hearty weeknight pasta" |
| Dietary restrictions | Multi-select pill toggles | Vegan, Vegetarian, Gluten-free, Dairy-free, Nut-free, Low-carb |
| Cuisine style | Dropdown | 10 options + "Any cuisine" |
| Time available | Dropdown | Under 20 min → 12 hrs |
| Skill level | Dropdown | Beginner / Intermediate / Advanced |
| Servings | Dropdown | 1, 2, 4, 6, 8 |
| Additional notes | Textarea | Free-form catch-all |
---
## API Design
### Endpoint
```
POST https://api.anthropic.com/v1/messages
```
### Required Headers
```js
{
'content-type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
}
```
> ⚠️ **Do not include `x-api-key`** when running inside the Claude.ai artifact environment — the platform injects the key automatically. Passing an empty string causes an auth failure (see Bug Fixes below).
### Model
`claude-sonnet-4-20250514` — good balance of quality and speed for recipe generation. Max tokens set to `1800`.
### Prompt Architecture
Two-part: system prompt + user message.
**System prompt** defines:
- Role: expert chef and culinary writer
- Explicit anti-vagueness instruction with a concrete example of bad vs. good instruction style
- Strict JSON-only output requirement (no markdown fences, no preamble)
- The exact JSON schema expected
- Hard rules: respect dietary restrictions, 510 steps, precise sensory details
**User message** is assembled dynamically from form state, filtering out any empty fields:
```
Ingredients available: pasta, garlic, tomato, tofu
Food goal: quick but protein packed pasta
Dietary restrictions: vegetarian, dairy-free
Time constraint: under 20 minutes
Servings: 6
```
### Response Schema
```json
{
"title": "string",
"description": "string",
"servings": 2,
"prepTime": "string",
"cookTime": "string",
"ingredients": [
{ "amount": "string", "unit": "string", "name": "string" }
],
"steps": [
{ "title": "string", "instruction": "string" }
],
"notes": "string | null"
}
```
- `servings` is typed as a **number** (not string) — explicitly called out in system prompt to avoid JSON parse issues
- `unit` can be omitted for whole/countable items (the renderer handles `undefined` gracefully)
- `notes` is nullable
---
## Prompt Engineering Decisions
### Anti-vagueness instruction
The single most impactful prompt decision. Rather than just saying "be detailed," the system prompt gives a concrete counter-example:
> Instead of "cook the onions", write "cook the onions in the butter over medium heat, stirring occasionally, for 810 minutes until they turn translucent and just begin to turn golden at the edges."
This anchors the model's output quality at the instruction level, not just the ingredient level.
### JSON-only output enforcement
The system prompt instructs the model to return nothing before or after the JSON. To be defensive against any stray text, the parser uses:
```js
const jsonStart = raw.indexOf('{');
const jsonEnd = raw.lastIndexOf('}');
const recipe = JSON.parse(raw.slice(jsonStart, jsonEnd + 1));
```
This is more robust than `JSON.parse(raw)` directly, which would fail on any leading/trailing characters.
---
## Design Decisions
### Tag input for ingredients
Standard text input UX isn't great for multi-item lists. Tags let users see all their ingredients at a glance and remove individual ones without retyping. Implemented in vanilla JS — no library.
- **Enter** or **comma** commits a tag
- **Backspace** on empty input removes the last tag
- Duplicate ingredients are silently ignored (case-insensitive)
### Pill toggles for dietary restrictions
Checkboxes are functional but visually coarse for this context. Pills feel more natural for preference selection and render well on mobile. The native `<input type="checkbox">` is hidden; state is managed via `.checked` property and a `.checked` CSS class on the parent.
### Typography split
- **Lora** (serif): recipe title, step numbers — editorial, food-magazine feel
- **DM Sans** (sans-serif): all UI chrome, form labels, body text — clean and modern
### Recipe output layout
- Meta chips (servings / prep / cook time) give a quick overview before diving in
- Ingredients in a bordered list table — scannable, easy to follow while cooking
- Steps as numbered items with a bold title + detail body — mirrors professional cookbook formatting
- Chef's notes in a left-bordered aside box — visually distinct from the main method
---
## Bug Fixes
### Bug 1 — "Load failed" / auth error on API call
**Root cause:** The initial implementation passed `'x-api-key': ''` as a header. In the Claude.ai artifact environment, the API key is injected by the platform — but only when the header is absent. Passing an explicit (even empty) `x-api-key` header overrides the platform injection and results in an authentication failure before the request reaches the model.
**Fix:** Remove the `x-api-key` header entirely from the fetch call.
```js
// ❌ Before
headers: {
'Content-Type': 'application/json',
'x-api-key': '', // <-- causes auth failure
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
}
// ✅ After
headers: {
'content-type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
}
```
### Bug 2 — Silent generic error message
**Root cause:** The original catch block showed a hardcoded "Something went wrong" message regardless of what the API returned, making it impossible to diagnose failures.
**Fix:** Parse the API error response body and surface the actual `error.message` field:
```js
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error?.message || `API error ${resp.status}`);
}
```
### Bug 3 — Missing required browser-access header
**Root cause:** `anthropic-dangerous-direct-browser-access: true` was absent in the initial version, which causes the API to reject browser-origin requests as a CORS/security policy enforcement.
**Fix:** Added the header explicitly to all fetch calls.
---
## Possible Next Steps
- **Streaming** — use the Anthropic streaming API so the recipe appears token by token rather than all at once after a delay
- **Persistent recipe book** — use the Claude.ai artifact storage API to save generated recipes across sessions
- **Serving scaler** — the structured JSON makes it trivial to multiply ingredient amounts; add a stepper UI to the output
- **Nutrition estimates** — add a `nutritionFacts` field to the JSON schema and ask the model to estimate macros
- **Shopping list view** — cross-reference the generated ingredients against what the user said they have, and produce a "what to buy" list
- **Image generation** — call an image model with the recipe title and description to generate a hero photo
- **Export to PDF** — render the recipe output as a printable/downloadable PDF

351
recipe-generator.html Normal file
View File

@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Recipe Generator</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400;0,600;1,400&family=DM+Sans:wght@300;400;500&display=swap');
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--color-text-primary: #1a1a1a;
--color-text-secondary: #6b6b6b;
--color-background-primary: #ffffff;
--color-background-secondary: #f5f5f4;
--color-background-danger: #fef2f2;
--color-text-danger: #b91c1c;
--color-border-primary: #a3a3a3;
--color-border-secondary: #d4d4d4;
--color-border-tertiary: #e5e5e5;
--border-radius-md: 8px;
--border-radius-lg: 12px;
}
body {
font-family: 'DM Sans', sans-serif;
background: var(--color-background-primary);
color: var(--color-text-primary);
padding: 2rem;
display: flex;
justify-content: center;
}
.app { width: 100%; max-width: 680px; padding: 2rem 0; }
.header { margin-bottom: 2rem; border-bottom: 0.5px solid var(--color-border-tertiary); padding-bottom: 1.25rem; }
.header h1 { font-family: 'Lora', serif; font-size: 26px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 4px; }
.header p { font-size: 14px; color: var(--color-text-secondary); font-weight: 300; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
.form-field { display: flex; flex-direction: column; gap: 6px; }
.form-field.full { grid-column: 1 / -1; }
label { font-size: 12px; font-weight: 500; color: var(--color-text-secondary); letter-spacing: 0.04em; text-transform: uppercase; }
input, textarea, select {
font-family: 'DM Sans', sans-serif; font-size: 14px; padding: 9px 12px;
border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md);
background: var(--color-background-primary); color: var(--color-text-primary);
width: 100%; transition: border-color 0.15s; outline: none;
}
input:focus, textarea:focus, select:focus { border-color: var(--color-border-primary); }
textarea { resize: vertical; min-height: 72px; line-height: 1.5; }
.tags-input-wrapper {
border: 0.5px solid var(--color-border-secondary); border-radius: var(--border-radius-md);
background: var(--color-background-primary); padding: 6px 8px;
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
cursor: text; min-height: 40px; transition: border-color 0.15s;
}
.tags-input-wrapper:focus-within { border-color: var(--color-border-primary); }
.tag { background: var(--color-background-secondary); border: 0.5px solid var(--color-border-tertiary); border-radius: 4px; padding: 2px 8px; font-size: 13px; display: flex; align-items: center; gap: 5px; white-space: nowrap; }
.tag-x { cursor: pointer; color: var(--color-text-secondary); font-size: 15px; line-height: 1; }
.tag-x:hover { color: var(--color-text-primary); }
.tags-input { border: none !important; padding: 2px 4px !important; flex: 1; min-width: 100px; font-size: 14px; background: transparent !important; outline: none !important; }
.checkboxes { display: flex; flex-wrap: wrap; gap: 8px; }
.check-pill { display: flex; align-items: center; gap: 6px; padding: 5px 12px; border: 0.5px solid var(--color-border-secondary); border-radius: 20px; font-size: 13px; cursor: pointer; transition: all 0.12s; background: var(--color-background-primary); user-select: none; }
.check-pill input { display: none; }
.check-pill.checked { background: var(--color-background-secondary); border-color: var(--color-border-primary); font-weight: 500; }
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--color-border-secondary); flex-shrink: 0; }
.check-pill.checked .dot { background: var(--color-text-primary); }
.generate-btn { width: 100%; padding: 11px; background: var(--color-text-primary); color: var(--color-background-primary); border: none; border-radius: var(--border-radius-md); font-family: 'DM Sans', sans-serif; font-size: 15px; font-weight: 500; cursor: pointer; margin-top: 8px; transition: opacity 0.15s; }
.generate-btn:hover { opacity: 0.85; }
.generate-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.status { text-align: center; padding: 2rem 0; color: var(--color-text-secondary); font-size: 14px; font-style: italic; }
.recipe-out { margin-top: 2.5rem; border-top: 0.5px solid var(--color-border-tertiary); padding-top: 2rem; animation: fadein 0.4s ease; }
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
.recipe-title { font-family: 'Lora', serif; font-size: 28px; font-weight: 600; letter-spacing: -0.5px; margin-bottom: 6px; }
.recipe-desc { font-size: 14px; color: var(--color-text-secondary); font-style: italic; margin-bottom: 1.25rem; line-height: 1.6; }
.meta-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 1.75rem; }
.meta-chip { background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 6px 14px; font-size: 13px; color: var(--color-text-secondary); }
.meta-chip strong { color: var(--color-text-primary); font-weight: 500; }
.section-label { font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--color-text-secondary); margin-bottom: 10px; margin-top: 1.5rem; }
.ingredients-list { list-style: none; border: 0.5px solid var(--color-border-tertiary); border-radius: var(--border-radius-lg); overflow: hidden; }
.ingredients-list li { padding: 9px 16px; font-size: 14px; border-bottom: 0.5px solid var(--color-border-tertiary); display: flex; gap: 10px; align-items: baseline; }
.ingredients-list li:last-child { border-bottom: none; }
.ing-amount { font-weight: 500; min-width: 60px; }
.ing-name { color: var(--color-text-secondary); }
.steps-list { list-style: none; }
.step-item { display: flex; gap: 16px; margin-bottom: 1.25rem; }
.step-num { font-family: 'Lora', serif; font-size: 18px; font-weight: 600; color: var(--color-text-secondary); min-width: 28px; line-height: 1.5; }
.step-title { font-weight: 500; font-size: 14px; margin-bottom: 4px; }
.step-instr { font-size: 14px; line-height: 1.7; color: var(--color-text-secondary); }
.notes-box { background: var(--color-background-secondary); border-radius: 0 var(--border-radius-md) var(--border-radius-md) 0; padding: 12px 16px; font-size: 14px; line-height: 1.6; color: var(--color-text-secondary); margin-top: 1.5rem; border-left: 2px solid var(--color-border-primary); }
.error-msg { background: var(--color-background-danger); color: var(--color-text-danger); border-radius: var(--border-radius-md); padding: 12px 16px; font-size: 13px; margin-top: 1rem; line-height: 1.5; }
.spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.35); border-top-color: #fff; border-radius: 50%; animation: spin 0.7s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="app">
<div class="header">
<h1>Recipe Generator</h1>
<p>Tell the AI what you have and what you want — it handles the rest.</p>
</div>
<div class="form-grid">
<div class="form-field full">
<label>Ingredients on hand</label>
<div class="tags-input-wrapper" id="ing-wrapper">
<input class="tags-input" id="ing-input" placeholder="Type an ingredient, press Enter..." />
</div>
</div>
<div class="form-field full">
<label>What do you want to make?</label>
<input type="text" id="goal" placeholder="e.g. a hearty weeknight pasta, a light summer salad..." />
</div>
<div class="form-field full">
<label>Dietary restrictions</label>
<div class="checkboxes">
<label class="check-pill"><input type="checkbox" value="vegan"><span class="dot"></span>Vegan</label>
<label class="check-pill"><input type="checkbox" value="vegetarian"><span class="dot"></span>Vegetarian</label>
<label class="check-pill"><input type="checkbox" value="gluten-free"><span class="dot"></span>Gluten-free</label>
<label class="check-pill"><input type="checkbox" value="dairy-free"><span class="dot"></span>Dairy-free</label>
<label class="check-pill"><input type="checkbox" value="nut-free"><span class="dot"></span>Nut-free</label>
<label class="check-pill"><input type="checkbox" value="low-carb"><span class="dot"></span>Low-carb</label>
</div>
</div>
<div class="form-field">
<label>Cuisine style</label>
<select id="cuisine">
<option value="">Any cuisine</option>
<option>Italian</option><option>Mexican</option><option>Asian fusion</option>
<option>Mediterranean</option><option>French</option><option>Indian</option>
<option>American</option><option>Middle Eastern</option><option>Japanese</option><option>Thai</option>
</select>
</div>
<div class="form-field">
<label>Time available</label>
<select id="time">
<option value="">No limit</option>
<option value="under 20 minutes">Under 20 minutes</option>
<option value="under 30 minutes">Under 30 minutes</option>
<option value="under 45 minutes">Under 45 minutes</option>
<option value="under 1 hour">Under 1 hour</option>
<option value="12 hours">12 hours</option>
</select>
</div>
<div class="form-field">
<label>Skill level</label>
<select id="skill">
<option value="">Any level</option>
<option value="beginner-friendly">Beginner-friendly</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced chef techniques</option>
</select>
</div>
<div class="form-field">
<label>Servings</label>
<select id="servings">
<option value="1">1</option><option value="2" selected>2</option>
<option value="4">4</option><option value="6">6</option><option value="8">8</option>
</select>
</div>
<div class="form-field full">
<label>Any other notes or preferences</label>
<textarea id="notes" placeholder="e.g. my kids hate mushrooms, I want it spicy, make it impressive enough for guests..."></textarea>
</div>
</div>
<button class="generate-btn" id="gen-btn" onclick="generate()">Generate my recipe</button>
<div id="error-area"></div>
<div id="recipe-area"></div>
</div>
<script>
const ingredients = [];
const ingWrapper = document.getElementById('ing-wrapper');
const ingInput = document.getElementById('ing-input');
ingWrapper.addEventListener('click', () => ingInput.focus());
ingInput.addEventListener('keydown', e => {
if ((e.key === 'Enter' || e.key === ',') && ingInput.value.trim()) {
e.preventDefault();
addTag(ingInput.value.trim().replace(/,$/, ''));
ingInput.value = '';
} else if (e.key === 'Backspace' && !ingInput.value && ingredients.length) {
removeTag(ingredients.length - 1);
}
});
function addTag(val) {
if (!val || ingredients.includes(val.toLowerCase())) return;
ingredients.push(val.toLowerCase());
const tag = document.createElement('div');
tag.className = 'tag';
tag.innerHTML = val + '<span class="tag-x">×</span>';
tag.querySelector('.tag-x').addEventListener('click', (e) => { e.stopPropagation(); removeTagByVal(val.toLowerCase()); });
ingWrapper.insertBefore(tag, ingInput);
}
function removeTagByVal(val) {
const i = ingredients.indexOf(val);
if (i > -1) { ingredients.splice(i, 1); renderTags(); }
}
function removeTag(idx) { ingredients.splice(idx, 1); renderTags(); }
function renderTags() {
ingWrapper.querySelectorAll('.tag').forEach(t => t.remove());
const copy = [...ingredients]; ingredients.length = 0;
copy.forEach(i => addTag(i));
}
document.querySelectorAll('.check-pill').forEach(pill => {
pill.addEventListener('click', () => {
const cb = pill.querySelector('input');
cb.checked = !cb.checked;
pill.classList.toggle('checked', cb.checked);
});
});
async function generate() {
const goal = document.getElementById('goal').value.trim();
if (!goal && ingredients.length === 0) {
showError('Please add some ingredients or describe what you want to make.');
return;
}
const restrictions = [...document.querySelectorAll('.check-pill input:checked')].map(c => c.value);
const cuisine = document.getElementById('cuisine').value;
const time = document.getElementById('time').value;
const skill = document.getElementById('skill').value;
const servings = document.getElementById('servings').value;
const notes = document.getElementById('notes').value.trim();
const btn = document.getElementById('gen-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Generating…';
document.getElementById('error-area').innerHTML = '';
document.getElementById('recipe-area').innerHTML = '<div class="status">Crafting your recipe with care…</div>';
const userPrompt = [
ingredients.length ? `Ingredients available: ${ingredients.join(', ')}` : '',
goal ? `Food goal: ${goal}` : '',
restrictions.length ? `Dietary restrictions: ${restrictions.join(', ')}` : '',
cuisine ? `Preferred cuisine: ${cuisine}` : '',
time ? `Time constraint: ${time}` : '',
skill ? `Skill level: ${skill}` : '',
`Servings: ${servings}`,
notes ? `Additional preferences: ${notes}` : '',
].filter(Boolean).join('\n');
const systemPrompt = `You are an expert chef and culinary writer. Create a complete, precise recipe based on the user's inputs.
CRITICAL: Instructions must be specific — never vague. Include exact temperatures, times, visual cues, sounds, smells, and textures. Example: instead of "cook the onions", write "cook the onions in the butter over medium heat, stirring occasionally, for 810 minutes until they turn translucent and just begin to turn golden at the edges."
Respond with ONLY a valid JSON object — no markdown fences, no backticks, no explanation, nothing before or after the JSON. Use this exact structure:
{"title":"string","description":"string","servings":2,"prepTime":"string","cookTime":"string","ingredients":[{"amount":"string","unit":"string","name":"string"}],"steps":[{"title":"string","instruction":"string"}],"notes":"string or null"}
Rules: use provided ingredients as the base, add pantry staples as needed, 510 steps, each instruction 13 richly detailed sentences, respect all dietary restrictions strictly.`;
try {
const resp = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'content-type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-dangerous-direct-browser-access': 'true'
},
body: JSON.stringify({
model: 'claude-sonnet-4-20250514',
max_tokens: 1800,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }]
})
});
const data = await resp.json();
if (!resp.ok) {
throw new Error(data.error?.message || `API error ${resp.status}`);
}
const raw = (data.content || []).map(b => b.text || '').join('').trim();
const jsonStart = raw.indexOf('{');
const jsonEnd = raw.lastIndexOf('}');
if (jsonStart === -1 || jsonEnd === -1) throw new Error('No JSON found in response');
const recipe = JSON.parse(raw.slice(jsonStart, jsonEnd + 1));
renderRecipe(recipe);
} catch (err) {
document.getElementById('recipe-area').innerHTML = '';
showError('Error: ' + (err.message || 'Unknown error. Please try again.'));
}
btn.disabled = false;
btn.innerHTML = 'Generate my recipe';
}
function showError(msg) {
document.getElementById('error-area').innerHTML = `<div class="error-msg">${msg}</div>`;
}
function renderRecipe(r) {
const ings = (r.ingredients || []).map(ing =>
`<li><span class="ing-amount">${ing.amount}${ing.unit ? ' ' + ing.unit : ''}</span><span class="ing-name">${ing.name}</span></li>`
).join('');
const steps = (r.steps || []).map((s, i) =>
`<li class="step-item">
<span class="step-num">${i + 1}</span>
<div>
<div class="step-title">${s.title}</div>
<div class="step-instr">${s.instruction}</div>
</div>
</li>`
).join('');
document.getElementById('recipe-area').innerHTML = `
<div class="recipe-out">
<div class="recipe-title">${r.title}</div>
<div class="recipe-desc">${r.description}</div>
<div class="meta-row">
<div class="meta-chip"><strong>${r.servings}</strong> servings</div>
<div class="meta-chip">Prep <strong>${r.prepTime}</strong></div>
<div class="meta-chip">Cook <strong>${r.cookTime}</strong></div>
</div>
<div class="section-label">Ingredients</div>
<ul class="ingredients-list">${ings}</ul>
<div class="section-label">Method</div>
<ol class="steps-list">${steps}</ol>
${r.notes ? `<div class="notes-box">${r.notes}</div>` : ''}
</div>`;
}
</script>
</body>
</html>