Initial copy
Some checks are pending
Build and push Docker image / build-and-push (push) Waiting to run

This commit is contained in:
David 2026-08-20 21:26:16 +02:00
parent 5fb8cdc471
commit 07c17d1359
13 changed files with 906 additions and 0 deletions

View file

@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(chromium-cli --session pizza)",
"WebSearch"
]
}
}

5
.dockerignore Normal file
View file

@ -0,0 +1,5 @@
.git
.claude
pizza_recipes.yaml
Dockerfile
.dockerignore

53
.github/workflows/docker-publish.yml vendored Normal file
View 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
View 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
View 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, "&lt;")
.replace(/>/g, "&gt;");
}
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
View 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
View 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
View 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"
}
]

View 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.

View 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.

View 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.

View 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
View 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);
}