Initial copy
Some checks are pending
Build and push Docker image / build-and-push (push) Waiting to run
Some checks are pending
Build and push Docker image / build-and-push (push) Waiting to run
This commit is contained in:
parent
5fb8cdc471
commit
07c17d1359
13 changed files with 906 additions and 0 deletions
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(chromium-cli --session pizza)",
|
||||||
|
"WebSearch"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
pizza_recipes.yaml
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
53
.github/workflows/docker-publish.yml
vendored
Normal file
53
.github/workflows/docker-publish.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
name: Build and push Docker image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set lowercase image name
|
||||||
|
run: echo "IMAGE_NAME=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
- name: Log in to GHCR
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=sha,format=short
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
6
Dockerfile
Normal file
6
Dockerfile
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
COPY index.html style.css app.js recipes.json /usr/share/nginx/html/
|
||||||
|
COPY recipes/ /usr/share/nginx/html/recipes/
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
228
app.js
Normal file
228
app.js
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
const RECIPES_URL = "recipes.json";
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
recipes: [],
|
||||||
|
selectedId: null,
|
||||||
|
sliderValues: {}, // { [recipeId]: { hydration, doughBalls, doughBallSizeG } }
|
||||||
|
markdownCache: {}, // { [recipeId]: htmlString }
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
return str
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function inlineMarkdown(text) {
|
||||||
|
return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownToHtml(md) {
|
||||||
|
const lines = md.split("\n");
|
||||||
|
let html = "";
|
||||||
|
let inList = false;
|
||||||
|
|
||||||
|
const closeList = () => {
|
||||||
|
if (inList) {
|
||||||
|
html += "</ol>";
|
||||||
|
inList = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawLine of lines) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line) {
|
||||||
|
closeList();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||||
|
if (headingMatch) {
|
||||||
|
closeList();
|
||||||
|
const level = Math.min(headingMatch[1].length + 1, 6); // ## -> h3
|
||||||
|
html += `<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const listMatch = line.match(/^\d+\.\s+(.*)$/);
|
||||||
|
if (listMatch) {
|
||||||
|
if (!inList) {
|
||||||
|
html += "<ol>";
|
||||||
|
inList = true;
|
||||||
|
}
|
||||||
|
html += `<li>${inlineMarkdown(listMatch[1])}</li>`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
html += `<p>${inlineMarkdown(line)}</p>`;
|
||||||
|
}
|
||||||
|
closeList();
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeIngredients(recipe, values) {
|
||||||
|
const scaleFactor =
|
||||||
|
(values.doughBalls * values.doughBallSizeG) /
|
||||||
|
(recipe.baseDoughBalls * recipe.baseDoughBallSizeG);
|
||||||
|
|
||||||
|
const baseFlour = recipe.ingredients.find((i) => i.role === "flour").baseAmount;
|
||||||
|
const baseWater = recipe.ingredients.find((i) => i.role === "water").baseAmount;
|
||||||
|
const flourWaterSum = (baseFlour + baseWater) * scaleFactor;
|
||||||
|
const flour = flourWaterSum / (1 + values.hydration / 100);
|
||||||
|
const water = flourWaterSum - flour;
|
||||||
|
|
||||||
|
return recipe.ingredients.map((ing) => {
|
||||||
|
let amount;
|
||||||
|
if (ing.role === "flour") amount = flour;
|
||||||
|
else if (ing.role === "water") amount = water;
|
||||||
|
else amount = ing.baseAmount * scaleFactor;
|
||||||
|
return { ...ing, amount: Math.round(amount) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPicker() {
|
||||||
|
const nav = document.getElementById("recipe-picker");
|
||||||
|
nav.innerHTML = "";
|
||||||
|
for (const recipe of state.recipes) {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.textContent = recipe.name;
|
||||||
|
btn.className = recipe.id === state.selectedId ? "active" : "";
|
||||||
|
btn.addEventListener("click", () => selectRecipe(recipe.id));
|
||||||
|
nav.appendChild(btn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseHydration(recipe) {
|
||||||
|
const flour = recipe.ingredients.find((i) => i.role === "flour").baseAmount;
|
||||||
|
const water = recipe.ingredients.find((i) => i.role === "water").baseAmount;
|
||||||
|
return Math.round((water / flour) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getInstructionsHtml(recipe) {
|
||||||
|
if (state.markdownCache[recipe.id]) return state.markdownCache[recipe.id];
|
||||||
|
const res = await fetch(recipe.instructionsFile);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${recipe.instructionsFile}`);
|
||||||
|
const md = await res.text();
|
||||||
|
const html = markdownToHtml(md);
|
||||||
|
state.markdownCache[recipe.id] = html;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIngredients(recipe) {
|
||||||
|
const values = state.sliderValues[recipe.id];
|
||||||
|
const ingredients = computeIngredients(recipe, values);
|
||||||
|
const list = ingredients
|
||||||
|
.map(
|
||||||
|
(ing) =>
|
||||||
|
`<li><span>${escapeHtml(ing.name)}</span><span class="amount">${ing.amount}${ing.unit}</span></li>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
const container = document.getElementById("ingredients-list");
|
||||||
|
if (container) container.innerHTML = list;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSliderChange(recipe) {
|
||||||
|
const panel = document.getElementById("recipe-panel");
|
||||||
|
const hydrationInput = panel.querySelector("#slider-hydration");
|
||||||
|
const ballsInput = panel.querySelector("#slider-balls");
|
||||||
|
const sizeInput = panel.querySelector("#slider-size");
|
||||||
|
|
||||||
|
const values = {
|
||||||
|
hydration: Number(hydrationInput.value),
|
||||||
|
doughBalls: Number(ballsInput.value),
|
||||||
|
doughBallSizeG: Number(sizeInput.value),
|
||||||
|
};
|
||||||
|
state.sliderValues[recipe.id] = values;
|
||||||
|
|
||||||
|
panel.querySelector("#value-hydration").textContent = `${values.hydration}%`;
|
||||||
|
panel.querySelector("#value-balls").textContent = values.doughBalls;
|
||||||
|
panel.querySelector("#value-size").textContent = `${values.doughBallSizeG}g`;
|
||||||
|
|
||||||
|
renderIngredients(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderRecipePanel(recipe) {
|
||||||
|
const panel = document.getElementById("recipe-panel");
|
||||||
|
|
||||||
|
if (!state.sliderValues[recipe.id]) {
|
||||||
|
state.sliderValues[recipe.id] = {
|
||||||
|
hydration: baseHydration(recipe),
|
||||||
|
doughBalls: recipe.baseDoughBalls,
|
||||||
|
doughBallSizeG: recipe.baseDoughBallSizeG,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const values = state.sliderValues[recipe.id];
|
||||||
|
|
||||||
|
panel.innerHTML = `
|
||||||
|
<section class="recipe-panel">
|
||||||
|
<h2>${escapeHtml(recipe.name)}</h2>
|
||||||
|
<p class="recipe-desc">${escapeHtml(recipe.description)}</p>
|
||||||
|
<div class="recipe-meta">
|
||||||
|
<span>Difficulty: ${escapeHtml(recipe.difficulty)}</span>
|
||||||
|
<span>Total time: ${recipe.totalTimeHours}h</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sliders">
|
||||||
|
<div class="slider-field">
|
||||||
|
<label for="slider-hydration">Hydration <span class="value" id="value-hydration">${values.hydration}%</span></label>
|
||||||
|
<input type="range" id="slider-hydration" min="${recipe.hydrationRange.min}" max="${recipe.hydrationRange.max}" value="${values.hydration}">
|
||||||
|
</div>
|
||||||
|
<div class="slider-field">
|
||||||
|
<label for="slider-balls">Dough Balls <span class="value" id="value-balls">${values.doughBalls}</span></label>
|
||||||
|
<input type="range" id="slider-balls" min="${recipe.doughBallsRange.min}" max="${recipe.doughBallsRange.max}" value="${values.doughBalls}">
|
||||||
|
</div>
|
||||||
|
<div class="slider-field">
|
||||||
|
<label for="slider-size">Ball Size <span class="value" id="value-size">${values.doughBallSizeG}g</span></label>
|
||||||
|
<input type="range" id="slider-size" min="${recipe.doughBallSizeRange.min}" max="${recipe.doughBallSizeRange.max}" step="10" value="${values.doughBallSizeG}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ingredients">
|
||||||
|
<h3>Ingredients</h3>
|
||||||
|
<ul id="ingredients-list"></ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="instructions" id="instructions">
|
||||||
|
<p class="loading">Loading instructions…</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
|
||||||
|
["slider-hydration", "slider-balls", "slider-size"].forEach((id) => {
|
||||||
|
panel.querySelector(`#${id}`).addEventListener("input", () => onSliderChange(recipe));
|
||||||
|
});
|
||||||
|
|
||||||
|
renderIngredients(recipe);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const html = await getInstructionsHtml(recipe);
|
||||||
|
const instructionsEl = document.getElementById("instructions");
|
||||||
|
if (instructionsEl) instructionsEl.innerHTML = html;
|
||||||
|
} catch (err) {
|
||||||
|
const instructionsEl = document.getElementById("instructions");
|
||||||
|
if (instructionsEl) instructionsEl.innerHTML = `<p class="error">${escapeHtml(err.message)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRecipe(id) {
|
||||||
|
state.selectedId = id;
|
||||||
|
renderPicker();
|
||||||
|
const recipe = state.recipes.find((r) => r.id === id);
|
||||||
|
renderRecipePanel(recipe);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(RECIPES_URL);
|
||||||
|
if (!res.ok) throw new Error(`Failed to load ${RECIPES_URL}`);
|
||||||
|
state.recipes = await res.json();
|
||||||
|
if (state.recipes.length === 0) {
|
||||||
|
document.getElementById("recipe-panel").innerHTML = '<p class="error">No recipes found.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
selectRecipe(state.recipes[0].id);
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById("recipe-panel").innerHTML = `<p class="error">${escapeHtml(err.message)} — this app must be served over http(s), not opened directly as a file.</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init();
|
||||||
20
index.html
Normal file
20
index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Pizza Recipes</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="site-header">
|
||||||
|
<h1>Pizza Recipes</h1>
|
||||||
|
<p>Pick a dough, dial in your hydration and batch size.</p>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
<nav class="recipe-picker" id="recipe-picker"></nav>
|
||||||
|
<div id="recipe-panel"><p class="loading">Loading recipes…</p></div>
|
||||||
|
</main>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
295
pizza_recipes.yaml
Normal file
295
pizza_recipes.yaml
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
recipes:
|
||||||
|
- id: "neapolitan_pizza_dough"
|
||||||
|
name: "Neapolitan Pizza Dough"
|
||||||
|
description: "Traditional Italian thin crust pizza dough with long fermentation for maximum flavor"
|
||||||
|
difficulty: "Medium"
|
||||||
|
total_time_hours: 24
|
||||||
|
image_url: null
|
||||||
|
variables:
|
||||||
|
- name: "rise_time_hours"
|
||||||
|
display: "Rise Time (hours)"
|
||||||
|
default: 24
|
||||||
|
min: 6
|
||||||
|
max: 72
|
||||||
|
type: "integer"
|
||||||
|
unit: "hours"
|
||||||
|
- name: "dough_balls"
|
||||||
|
display: "Number of Dough Balls"
|
||||||
|
default: 4
|
||||||
|
min: 1
|
||||||
|
max: 12
|
||||||
|
type: "integer"
|
||||||
|
unit: "pieces"
|
||||||
|
- name: "dough_ball_size_g"
|
||||||
|
display: "Dough Ball Size (g)"
|
||||||
|
default: 250
|
||||||
|
min: 150
|
||||||
|
max: 400
|
||||||
|
type: "integer"
|
||||||
|
unit: "g"
|
||||||
|
- name: "ambient_temp_c"
|
||||||
|
display: "Room Temperature (°C)"
|
||||||
|
default: 22
|
||||||
|
min: 18
|
||||||
|
max: 30
|
||||||
|
type: "integer"
|
||||||
|
unit: "°C"
|
||||||
|
- name: "yeast_amount_g"
|
||||||
|
display: "Yeast Amount (g)"
|
||||||
|
default: 2
|
||||||
|
min: 1
|
||||||
|
max: 5
|
||||||
|
type: "decimal"
|
||||||
|
unit: "g"
|
||||||
|
ingredients:
|
||||||
|
- name: "Bread Flour"
|
||||||
|
amount: 1000
|
||||||
|
unit: "g"
|
||||||
|
category: "dry"
|
||||||
|
- name: "Water"
|
||||||
|
amount: 650
|
||||||
|
unit: "ml"
|
||||||
|
category: "liquid"
|
||||||
|
- name: "Salt"
|
||||||
|
amount: 20
|
||||||
|
unit: "g"
|
||||||
|
category: "seasoning"
|
||||||
|
- name: "Fresh Yeast"
|
||||||
|
amount: 2
|
||||||
|
unit: "g"
|
||||||
|
category: "leavening"
|
||||||
|
steps:
|
||||||
|
- id: "mix_ingredients"
|
||||||
|
name: "Mix ingredients"
|
||||||
|
description: "Combine 1kg flour, 650ml water, 20g salt, and {yeast_amount_g}g fresh yeast in a large bowl"
|
||||||
|
duration_minutes: 10
|
||||||
|
timing: "start"
|
||||||
|
temperature: "Room temperature"
|
||||||
|
notes: "Use lukewarm water (around 25°C)"
|
||||||
|
- id: "knead_dough"
|
||||||
|
name: "Knead dough"
|
||||||
|
description: "Knead the dough until smooth and elastic, about 10-15 minutes by hand"
|
||||||
|
duration_minutes: 15
|
||||||
|
timing: "after_previous"
|
||||||
|
notes: "Dough should be slightly sticky but manageable"
|
||||||
|
- id: "first_rise"
|
||||||
|
name: "First rise (bulk fermentation)"
|
||||||
|
description: "Let dough rise in covered bowl at room temperature"
|
||||||
|
duration_formula: "rise_time_hours * 0.4 * 60"
|
||||||
|
timing: "after_previous"
|
||||||
|
temperature: "{ambient_temp_c}°C"
|
||||||
|
notes: "Dough should roughly double in size"
|
||||||
|
- id: "divide_shape"
|
||||||
|
name: "Divide and shape"
|
||||||
|
description: "Divide dough into {dough_balls} equal portions and shape into balls"
|
||||||
|
duration_minutes: 15
|
||||||
|
timing: "after_previous"
|
||||||
|
notes: "Use a kitchen scale for even portions"
|
||||||
|
- id: "final_rise"
|
||||||
|
name: "Final rise"
|
||||||
|
description: "Let shaped dough balls rise until ready to use"
|
||||||
|
duration_formula: "rise_time_hours * 0.6 * 60"
|
||||||
|
timing: "after_previous"
|
||||||
|
temperature: "{ambient_temp_c}°C"
|
||||||
|
notes: "Dough is ready when it springs back slowly when poked"
|
||||||
|
|
||||||
|
- id: "new_york_pizza_dough"
|
||||||
|
name: "New York Style Pizza Dough"
|
||||||
|
description: "Classic New York pizza dough with a chewy texture and crispy bottom"
|
||||||
|
difficulty: "Easy"
|
||||||
|
total_time_hours: 48
|
||||||
|
image_url: null
|
||||||
|
variables:
|
||||||
|
- name: "rise_time_hours"
|
||||||
|
display: "Rise Time (hours)"
|
||||||
|
default: 48
|
||||||
|
min: 24
|
||||||
|
max: 96
|
||||||
|
type: "integer"
|
||||||
|
unit: "hours"
|
||||||
|
- name: "dough_balls"
|
||||||
|
display: "Number of Dough Balls"
|
||||||
|
default: 3
|
||||||
|
min: 1
|
||||||
|
max: 8
|
||||||
|
type: "integer"
|
||||||
|
unit: "pieces"
|
||||||
|
- name: "dough_ball_size_g"
|
||||||
|
display: "Dough Ball Size (g)"
|
||||||
|
default: 250
|
||||||
|
min: 150
|
||||||
|
max: 400
|
||||||
|
type: "integer"
|
||||||
|
unit: "g"
|
||||||
|
- name: "ambient_temp_c"
|
||||||
|
display: "Room Temperature (°C)"
|
||||||
|
default: 20
|
||||||
|
min: 18
|
||||||
|
max: 25
|
||||||
|
type: "integer"
|
||||||
|
unit: "°C"
|
||||||
|
- name: "oil_amount_ml"
|
||||||
|
display: "Olive Oil (ml)"
|
||||||
|
default: 30
|
||||||
|
min: 20
|
||||||
|
max: 50
|
||||||
|
type: "integer"
|
||||||
|
unit: "ml"
|
||||||
|
ingredients:
|
||||||
|
- name: "Bread Flour"
|
||||||
|
amount: 500
|
||||||
|
unit: "g"
|
||||||
|
category: "dry"
|
||||||
|
- name: "Water"
|
||||||
|
amount: 320
|
||||||
|
unit: "ml"
|
||||||
|
category: "liquid"
|
||||||
|
- name: "Salt"
|
||||||
|
amount: 10
|
||||||
|
unit: "g"
|
||||||
|
category: "seasoning"
|
||||||
|
- name: "Active Dry Yeast"
|
||||||
|
amount: 3
|
||||||
|
unit: "g"
|
||||||
|
category: "leavening"
|
||||||
|
- name: "Olive Oil"
|
||||||
|
amount: 30
|
||||||
|
unit: "ml"
|
||||||
|
category: "fat"
|
||||||
|
steps:
|
||||||
|
- id: "mix_dry"
|
||||||
|
name: "Mix dry ingredients"
|
||||||
|
description: "Combine 500g bread flour, 10g salt, and 3g active dry yeast"
|
||||||
|
duration_minutes: 5
|
||||||
|
timing: "start"
|
||||||
|
- id: "add_liquids"
|
||||||
|
name: "Add liquids"
|
||||||
|
description: "Add 320ml cool water and {oil_amount_ml}ml olive oil to dry ingredients"
|
||||||
|
duration_minutes: 5
|
||||||
|
timing: "after_previous"
|
||||||
|
notes: "Water should be around 18-20°C for slow fermentation"
|
||||||
|
- id: "mix_dough"
|
||||||
|
name: "Mix dough"
|
||||||
|
description: "Mix until dough comes together, don't overwork"
|
||||||
|
duration_minutes: 8
|
||||||
|
timing: "after_previous"
|
||||||
|
notes: "Dough will be slightly rough, this is normal"
|
||||||
|
- id: "bulk_ferment"
|
||||||
|
name: "Bulk fermentation"
|
||||||
|
description: "Let dough rise in oiled container in refrigerator"
|
||||||
|
duration_formula: "rise_time_hours * 0.7 * 60"
|
||||||
|
timing: "after_previous"
|
||||||
|
temperature: "4°C (refrigerator)"
|
||||||
|
notes: "Cold fermentation develops flavor"
|
||||||
|
- id: "portion_dough"
|
||||||
|
name: "Portion dough"
|
||||||
|
description: "Remove from fridge and divide into {dough_balls} portions"
|
||||||
|
duration_minutes: 10
|
||||||
|
timing: "after_previous"
|
||||||
|
temperature: "Room temperature"
|
||||||
|
- id: "final_proof"
|
||||||
|
name: "Final proofing"
|
||||||
|
description: "Let portions come to room temperature and final proof"
|
||||||
|
duration_formula: "rise_time_hours * 0.3 * 60"
|
||||||
|
timing: "after_previous"
|
||||||
|
temperature: "{ambient_temp_c}°C"
|
||||||
|
notes: "Dough is ready when it feels soft and pliable"
|
||||||
|
|
||||||
|
- id: "sicilian_pizza_dough"
|
||||||
|
name: "Sicilian Pizza Dough"
|
||||||
|
description: "Thick, airy Sicilian-style pizza dough perfect for deep dish pans"
|
||||||
|
difficulty: "Hard"
|
||||||
|
total_time_hours: 72
|
||||||
|
image_url: null
|
||||||
|
variables:
|
||||||
|
- name: "rise_time_hours"
|
||||||
|
display: "Rise Time (hours)"
|
||||||
|
default: 72
|
||||||
|
min: 48
|
||||||
|
max: 120
|
||||||
|
type: "integer"
|
||||||
|
unit: "hours"
|
||||||
|
- name: "dough_balls"
|
||||||
|
display: "Number of Dough Balls"
|
||||||
|
default: 1
|
||||||
|
min: 1
|
||||||
|
max: 4
|
||||||
|
type: "integer"
|
||||||
|
unit: "pieces"
|
||||||
|
- name: "dough_ball_size_g"
|
||||||
|
display: "Dough Ball Size (g)"
|
||||||
|
default: 250
|
||||||
|
min: 150
|
||||||
|
max: 400
|
||||||
|
type: "integer"
|
||||||
|
unit: "g"
|
||||||
|
- name: "pan_size_cm"
|
||||||
|
display: "Pan Size (cm)"
|
||||||
|
default: 30
|
||||||
|
min: 25
|
||||||
|
max: 40
|
||||||
|
type: "integer"
|
||||||
|
unit: "cm"
|
||||||
|
- name: "ambient_temp_c"
|
||||||
|
display: "Room Temperature (°C)"
|
||||||
|
default: 21
|
||||||
|
min: 18
|
||||||
|
max: 24
|
||||||
|
type: "integer"
|
||||||
|
unit: "°C"
|
||||||
|
- name: "hydration_percent"
|
||||||
|
display: "Hydration (%)"
|
||||||
|
default: 75
|
||||||
|
min: 70
|
||||||
|
max: 80
|
||||||
|
type: "integer"
|
||||||
|
unit: "%"
|
||||||
|
ingredients:
|
||||||
|
- name: "Bread Flour"
|
||||||
|
amount: 600
|
||||||
|
unit: "g"
|
||||||
|
category: "dry"
|
||||||
|
- name: "Water"
|
||||||
|
amount: 450
|
||||||
|
unit: "ml"
|
||||||
|
category: "liquid"
|
||||||
|
- name: "Salt"
|
||||||
|
amount: 12
|
||||||
|
unit: "g"
|
||||||
|
category: "seasoning"
|
||||||
|
- name: "Active Dry Yeast"
|
||||||
|
amount: 1
|
||||||
|
unit: "g"
|
||||||
|
category: "leavening"
|
||||||
|
steps:
|
||||||
|
- id: "autolyse"
|
||||||
|
name: "Autolyse"
|
||||||
|
description: "Mix 600g bread flour with calculated water amount, let rest"
|
||||||
|
duration_minutes: 30
|
||||||
|
timing: "start"
|
||||||
|
notes: "Water amount: 600g × {hydration_percent}/100"
|
||||||
|
- id: "add_salt_yeast"
|
||||||
|
name: "Add salt and yeast"
|
||||||
|
description: "Add 12g salt and 1g active dry yeast to autolyse"
|
||||||
|
duration_minutes: 5
|
||||||
|
timing: "after_previous"
|
||||||
|
- id: "mix_develop"
|
||||||
|
name: "Mix and develop"
|
||||||
|
description: "Mix thoroughly and perform stretch and folds"
|
||||||
|
duration_minutes: 20
|
||||||
|
timing: "after_previous"
|
||||||
|
notes: "Perform 4 sets of stretch and folds, 5 minutes apart"
|
||||||
|
- id: "bulk_fermentation"
|
||||||
|
name: "Bulk fermentation"
|
||||||
|
description: "First rise in oiled container"
|
||||||
|
duration_formula: "rise_time_hours * 0.5 * 60"
|
||||||
|
timing: "after_previous"
|
||||||
|
temperature: "{ambient_temp_c}°C"
|
||||||
|
notes: "Dough should increase by 50-70%"
|
||||||
|
- id: "pan_proof"
|
||||||
|
name: "Pan proofing"
|
||||||
|
description: "Transfer to oiled {pan_size_cm}cm pan and spread gently"
|
||||||
|
duration_formula: "rise_time_hours * 0.5 * 60"
|
||||||
|
timing: "after_previous"
|
||||||
|
temperature: "{ambient_temp_c}°C"
|
||||||
|
notes: "Don't force the dough, let it relax and spread naturally"
|
||||||
80
recipes.json
Normal file
80
recipes.json
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "neapolitan_pizza_dough",
|
||||||
|
"name": "Neapolitan Pizza Dough",
|
||||||
|
"description": "Traditional Italian thin crust pizza dough with long fermentation for maximum flavor",
|
||||||
|
"difficulty": "Medium",
|
||||||
|
"totalTimeHours": 24,
|
||||||
|
"baseDoughBalls": 4,
|
||||||
|
"baseDoughBallSizeG": 250,
|
||||||
|
"doughBallsRange": { "min": 1, "max": 12 },
|
||||||
|
"doughBallSizeRange": { "min": 150, "max": 400 },
|
||||||
|
"hydrationRange": { "min": 55, "max": 80 },
|
||||||
|
"ingredients": [
|
||||||
|
{ "name": "Bread Flour", "baseAmount": 1000, "unit": "g", "role": "flour" },
|
||||||
|
{ "name": "Water", "baseAmount": 650, "unit": "ml", "role": "water" },
|
||||||
|
{ "name": "Salt", "baseAmount": 20, "unit": "g", "role": "fixed" },
|
||||||
|
{ "name": "Fresh Yeast", "baseAmount": 2, "unit": "g", "role": "fixed" }
|
||||||
|
],
|
||||||
|
"instructionsFile": "recipes/neapolitan_pizza_dough.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "new_york_pizza_dough",
|
||||||
|
"name": "New York Style Pizza Dough",
|
||||||
|
"description": "Classic New York pizza dough with a chewy texture and crispy bottom",
|
||||||
|
"difficulty": "Easy",
|
||||||
|
"totalTimeHours": 48,
|
||||||
|
"baseDoughBalls": 3,
|
||||||
|
"baseDoughBallSizeG": 250,
|
||||||
|
"doughBallsRange": { "min": 1, "max": 8 },
|
||||||
|
"doughBallSizeRange": { "min": 150, "max": 400 },
|
||||||
|
"hydrationRange": { "min": 55, "max": 80 },
|
||||||
|
"ingredients": [
|
||||||
|
{ "name": "Bread Flour", "baseAmount": 500, "unit": "g", "role": "flour" },
|
||||||
|
{ "name": "Water", "baseAmount": 320, "unit": "ml", "role": "water" },
|
||||||
|
{ "name": "Salt", "baseAmount": 10, "unit": "g", "role": "fixed" },
|
||||||
|
{ "name": "Active Dry Yeast", "baseAmount": 3, "unit": "g", "role": "fixed" },
|
||||||
|
{ "name": "Olive Oil", "baseAmount": 30, "unit": "ml", "role": "fixed" }
|
||||||
|
],
|
||||||
|
"instructionsFile": "recipes/new_york_pizza_dough.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sicilian_pizza_dough",
|
||||||
|
"name": "Sicilian Pizza Dough",
|
||||||
|
"description": "Thick, airy Sicilian-style pizza dough perfect for deep dish pans",
|
||||||
|
"difficulty": "Hard",
|
||||||
|
"totalTimeHours": 72,
|
||||||
|
"baseDoughBalls": 1,
|
||||||
|
"baseDoughBallSizeG": 250,
|
||||||
|
"doughBallsRange": { "min": 1, "max": 4 },
|
||||||
|
"doughBallSizeRange": { "min": 150, "max": 400 },
|
||||||
|
"hydrationRange": { "min": 65, "max": 85 },
|
||||||
|
"ingredients": [
|
||||||
|
{ "name": "Bread Flour", "baseAmount": 600, "unit": "g", "role": "flour" },
|
||||||
|
{ "name": "Water", "baseAmount": 450, "unit": "ml", "role": "water" },
|
||||||
|
{ "name": "Salt", "baseAmount": 12, "unit": "g", "role": "fixed" },
|
||||||
|
{ "name": "Active Dry Yeast", "baseAmount": 1, "unit": "g", "role": "fixed" }
|
||||||
|
],
|
||||||
|
"instructionsFile": "recipes/sicilian_pizza_dough.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "biga_pizza_dough",
|
||||||
|
"name": "100% Biga Pizza Dough",
|
||||||
|
"description": "Two-day preferment method using a stiff biga starter for exceptional flavor and an airy, digestible crumb",
|
||||||
|
"difficulty": "Hard",
|
||||||
|
"totalTimeHours": 24,
|
||||||
|
"baseDoughBalls": 6,
|
||||||
|
"baseDoughBallSizeG": 280,
|
||||||
|
"doughBallsRange": { "min": 1, "max": 12 },
|
||||||
|
"doughBallSizeRange": { "min": 150, "max": 400 },
|
||||||
|
"hydrationRange": { "min": 55, "max": 75 },
|
||||||
|
"ingredients": [
|
||||||
|
{ "name": "Bread Flour", "baseAmount": 1000, "unit": "g", "role": "flour" },
|
||||||
|
{ "name": "Water", "baseAmount": 660, "unit": "ml", "role": "water" },
|
||||||
|
{ "name": "Salt", "baseAmount": 25, "unit": "g", "role": "fixed" },
|
||||||
|
{ "name": "Dry Yeast", "baseAmount": 10, "unit": "g", "role": "fixed" },
|
||||||
|
{ "name": "Malt Extract", "baseAmount": 10, "unit": "g", "role": "fixed" }
|
||||||
|
],
|
||||||
|
"instructionsFile": "recipes/biga_pizza_dough.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
14
recipes/biga_pizza_dough.md
Normal file
14
recipes/biga_pizza_dough.md
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
**Day 1 — Make the biga**
|
||||||
|
|
||||||
|
1. **Mix the biga** (10 min) — Combine the flour, 450g of the water, and 10g dry yeast into a dry, lumpy mixture. It shouldn't come together into a smooth dough — just no dry flour left.
|
||||||
|
2. **Rest the biga** (~17 hours) — Cover and let rest at 16-18°C for 16-18 hours, until puffy and full of bubbles.
|
||||||
|
|
||||||
|
**Day 2 — Make the final dough**
|
||||||
|
|
||||||
|
3. **Mix biga with malt and water** (5 min) — Break up the rested biga and mix with 10g malt extract and 150g of the remaining water on low speed.
|
||||||
|
4. **Add salt and remaining water** (5 min) — Add the salt, then mix on high speed for 4-5 minutes while gradually adding the last 60g water. Target a dough temperature of 23-24°C.
|
||||||
|
5. **Fold and rest** (20 min) — Fold the dough over itself a few times, then let it rest for 20 minutes.
|
||||||
|
6. **Divide and ball** (15 min) — Divide into 6 portions of 280g each and shape into balls.
|
||||||
|
7. **Proof** (1-2 hours) — Let the balls rest at room temperature for 1-2 hours before baking. Alternatively, rest 1 hour and refrigerate until needed.
|
||||||
7
recipes/neapolitan_pizza_dough.md
Normal file
7
recipes/neapolitan_pizza_dough.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. **Mix ingredients** (10 min) — Combine the flour, water, salt, and 2g fresh yeast in a large bowl. Use lukewarm water, around 25°C.
|
||||||
|
2. **Knead dough** (15 min) — Knead until smooth and elastic, about 10-15 minutes by hand. Dough should be slightly sticky but manageable.
|
||||||
|
3. **First rise** (~9.6 hours) — Let dough rise in a covered bowl at 22°C. Dough should roughly double in size.
|
||||||
|
4. **Divide and shape** (15 min) — Divide dough into 4 equal portions and shape into balls. Use a kitchen scale for even portions.
|
||||||
|
5. **Final rise** (~14.4 hours) — Let shaped dough balls rise at 22°C until ready to use. Dough is ready when it springs back slowly when poked.
|
||||||
8
recipes/new_york_pizza_dough.md
Normal file
8
recipes/new_york_pizza_dough.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. **Mix dry ingredients** (5 min) — Combine the flour, salt, and active dry yeast.
|
||||||
|
2. **Add liquids** (5 min) — Add the water and 30ml olive oil to the dry ingredients. Water should be around 18-20°C for slow fermentation.
|
||||||
|
3. **Mix dough** (8 min) — Mix until dough comes together, don't overwork. Dough will be slightly rough, this is normal.
|
||||||
|
4. **Bulk fermentation** (~33.6 hours) — Let dough rise in an oiled container in the refrigerator at 4°C. Cold fermentation develops flavor.
|
||||||
|
5. **Portion dough** (10 min) — Remove from fridge and divide into 3 portions at room temperature.
|
||||||
|
6. **Final proofing** (~14.4 hours) — Let portions come to room temperature (20°C) and final proof. Dough is ready when it feels soft and pliable.
|
||||||
7
recipes/sicilian_pizza_dough.md
Normal file
7
recipes/sicilian_pizza_dough.md
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. **Autolyse** (30 min) — Mix the flour with the water, let rest. At 75% hydration that's 600g flour to 450g water.
|
||||||
|
2. **Add salt and yeast** (5 min) — Add the salt and active dry yeast to the autolyse.
|
||||||
|
3. **Mix and develop** (20 min) — Mix thoroughly and perform 4 sets of stretch and folds, 5 minutes apart.
|
||||||
|
4. **Bulk fermentation** (~36 hours) — First rise in an oiled container at 21°C. Dough should increase by 50-70%.
|
||||||
|
5. **Pan proofing** (~36 hours) — Transfer to an oiled 30cm pan and spread gently at 21°C. Don't force the dough, let it relax and spread naturally.
|
||||||
175
style.css
Normal file
175
style.css
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
:root {
|
||||||
|
--bg: #faf6f0;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--border: #e5ddd0;
|
||||||
|
--text: #2b2320;
|
||||||
|
--muted: #7a6f63;
|
||||||
|
--accent: #c0392b;
|
||||||
|
--accent-light: #f5e3e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
header.site-header {
|
||||||
|
padding: 2rem 1.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.site-header h1 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header.site-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-picker {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-picker button {
|
||||||
|
padding: 0.6rem 1.1rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-picker button:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-picker button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-panel h2 {
|
||||||
|
margin: 0 0 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-desc {
|
||||||
|
color: var(--muted);
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recipe-meta span {
|
||||||
|
background: var(--accent-light);
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sliders {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-bottom: 1.75rem;
|
||||||
|
padding: 1.25rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-field label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-field .value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-field input[type="range"] {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredients h3,
|
||||||
|
.instructions h3 {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredients ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0 0 1.75rem;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredients li {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.55rem 0.8rem;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingredients li .amount {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions ol {
|
||||||
|
padding-left: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.instructions li {
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading,
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue