diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..51436c9
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(chromium-cli --session pizza)",
+ "WebSearch"
+ ]
+ }
+}
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..8ab90b3
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,5 @@
+.git
+.claude
+pizza_recipes.yaml
+Dockerfile
+.dockerignore
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 0000000..6693045
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..a411161
--- /dev/null
+++ b/Dockerfile
@@ -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
diff --git a/app.js b/app.js
new file mode 100644
index 0000000..b4452ed
--- /dev/null
+++ b/app.js
@@ -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, ">");
+}
+
+function inlineMarkdown(text) {
+ return escapeHtml(text).replace(/\*\*(.+?)\*\*/g, "$1");
+}
+
+function markdownToHtml(md) {
+ const lines = md.split("\n");
+ let html = "";
+ let inList = false;
+
+ const closeList = () => {
+ if (inList) {
+ html += "";
+ 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 += `${inlineMarkdown(headingMatch[2])}`;
+ continue;
+ }
+ const listMatch = line.match(/^\d+\.\s+(.*)$/);
+ if (listMatch) {
+ if (!inList) {
+ html += "
";
+ inList = true;
+ }
+ html += `- ${inlineMarkdown(listMatch[1])}
`;
+ continue;
+ }
+ closeList();
+ html += `${inlineMarkdown(line)}
`;
+ }
+ 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) =>
+ `- ${escapeHtml(ing.name)}${ing.amount}${ing.unit}
`
+ )
+ .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 = `
+
+ ${escapeHtml(recipe.name)}
+ ${escapeHtml(recipe.description)}
+
+ Difficulty: ${escapeHtml(recipe.difficulty)}
+ Total time: ${recipe.totalTimeHours}h
+
+
+
+
+
+
+
+
Loading instructions…
+
+
+ `;
+
+ ["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 = `${escapeHtml(err.message)}
`;
+ }
+}
+
+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 = 'No recipes found.
';
+ return;
+ }
+ selectRecipe(state.recipes[0].id);
+ } catch (err) {
+ document.getElementById("recipe-panel").innerHTML = `${escapeHtml(err.message)} — this app must be served over http(s), not opened directly as a file.
`;
+ }
+}
+
+init();
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..a203b6a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,20 @@
+
+
+
+
+
+ Pizza Recipes
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pizza_recipes.yaml b/pizza_recipes.yaml
new file mode 100644
index 0000000..9995bb7
--- /dev/null
+++ b/pizza_recipes.yaml
@@ -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"
diff --git a/recipes.json b/recipes.json
new file mode 100644
index 0000000..9231304
--- /dev/null
+++ b/recipes.json
@@ -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"
+ }
+]
diff --git a/recipes/biga_pizza_dough.md b/recipes/biga_pizza_dough.md
new file mode 100644
index 0000000..86ffb03
--- /dev/null
+++ b/recipes/biga_pizza_dough.md
@@ -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.
diff --git a/recipes/neapolitan_pizza_dough.md b/recipes/neapolitan_pizza_dough.md
new file mode 100644
index 0000000..7b67cf1
--- /dev/null
+++ b/recipes/neapolitan_pizza_dough.md
@@ -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.
diff --git a/recipes/new_york_pizza_dough.md b/recipes/new_york_pizza_dough.md
new file mode 100644
index 0000000..1b4b99d
--- /dev/null
+++ b/recipes/new_york_pizza_dough.md
@@ -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.
diff --git a/recipes/sicilian_pizza_dough.md b/recipes/sicilian_pizza_dough.md
new file mode 100644
index 0000000..c88c117
--- /dev/null
+++ b/recipes/sicilian_pizza_dough.md
@@ -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.
diff --git a/style.css b/style.css
new file mode 100644
index 0000000..b851d67
--- /dev/null
+++ b/style.css
@@ -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);
+}