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