Academy v2: one trackable roasting lesson; every scene redesigned to layout discipline
Test and deploy / test-and-deploy (push) Successful in 1m2s

- Roasting is now a single continuous 11-slide lesson ('The roast,
  start to finish') merging the six lessons' content; brewing keeps its
  four per-family lessons
- Progress tracking: resume-where-you-left-off per lesson, progress bar
  and completed badge on the lesson cards (localStorage)
- Every scene rebuilt against a fixed safe area (nothing off-canvas),
  with animations that depict the narrated mechanism: contained
  thermometer fills, a drawn dip-and-turn for the turning point, chaff
  drifting off the swelling Maillard bean, crash/flick/stall on one
  annotated RoR chart, per-brewer water/grounds anchored to each
  silhouette's actual bowl, gauge needle sweeps instead of spinning
  clocks; multiline labels replace out-of-frame text
- 9 new narration clips generated, 23 orphaned ones removed (31 total)

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 09:22:40 -04:00
co-authored by Claude Fable 5
parent 64c1b3605b
commit 9448712a8a
35 changed files with 457 additions and 497 deletions
+384 -320
View File
@@ -5,9 +5,14 @@ import { BREW_SILHOUETTES, findBrewMethod } from "/shared/brew-data.js?v=__ASSET
const NS = "http://www.w3.org/2000/svg";
// Shared palette — every scene draws from the same set so the whole course reads as one system.
// ── Layout discipline ──────────────────────────────────────────────────────
// Canvas is 560×300. Everything must live inside the safe area x∈[30,530] y∈[24,266];
// the single caption line sits on the BASELINE at y=286 and stays ≤ 78 characters.
// Every animation must depict the mechanism the narration is describing — no decoration.
const C = {
green: "#7a8c5e",
greenDark: "#5d6b45",
yellow: "#d9ba5a",
tan: "#b98d54",
brown: "#8a5a30",
@@ -25,40 +30,49 @@ const C = {
function el(tag, attrs = {}, ...children) {
const node = document.createElementNS(NS, tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.setAttribute("class", v);
else node.setAttribute(k, v);
}
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
node.append(...children);
return node;
}
const text = (x, y, str, size = 12, fill = C.ink2, anchor = "middle", weight = "400") =>
el("text", { x, y, fill, "font-size": size, "text-anchor": anchor, "font-weight": weight, "font-family": "inherit" }, str);
const text = (x, y, str, size = 12.5, fill = C.ink2, anchor = "middle", weight = "400") =>
el("text", { x, y, fill, "font-size": size, "text-anchor": anchor, "font-weight": weight }, str);
const caption = (str, fill = C.ink2) => text(280, 286, str, 12.5, fill);
/** A coffee bean (side profile with centre cut) at x,y. */
/** Coffee bean, side profile with centre cut, centred on (x,y). Height ≈ 72·scale. */
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
const g = el("g", { transform: `translate(${x} ${y}) scale(${scale})`, class: opts.class ?? "" });
const g = el("g", {
transform: `translate(${x} ${y}) scale(${scale})`,
class: opts.class ?? "",
});
g.append(
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill }),
el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.22)" }),
);
if (opts.crack)
g.append(el("path", { d: "M-2 -30 L 5 -12 L -4 4 L 4 20 L -1 32", fill: "none", stroke: C.paper, "stroke-width": 2.4, class: "ac-crackline" }));
g.append(
el("path", {
d: "M-2 -30 L 5 -12 L -4 4 L 4 20 L -1 32",
fill: "none",
stroke: C.paper,
"stroke-width": 2.4,
class: "ac-crackline",
}),
);
return g;
}
function steamPaths(x, y, n = 3, cls = "ac-steam") {
function steamWisps(x, y, n = 3) {
const g = el("g");
for (let i = 0; i < n; i++)
g.append(
el("path", {
d: `M${x + (i - 1) * 16} ${y} c -6 -12, 6 -18, 0 -30 c -5 -10, 5 -16, 0 -26`,
d: `M${x + (i - (n - 1) / 2) * 18} ${y} c -5 -10, 5 -16, 0 -26 c -4 -8, 4 -14, 0 -22`,
fill: "none",
stroke: C.ink2,
"stroke-width": 2.5,
"stroke-width": 2.4,
"stroke-linecap": "round",
class: `${cls} ac-delay-${i}`,
opacity: 0.55,
class: `ac-steam ac-delay-${i}`,
opacity: 0.5,
}),
);
return g;
@@ -66,423 +80,451 @@ function steamPaths(x, y, n = 3, cls = "ac-steam") {
function flame(x, y, scale = 1) {
return el("g", { transform: `translate(${x} ${y}) scale(${scale})`, class: "ac-flame" },
el("path", { d: "M0 0 C 12 -14, 4 -26, 0 -36 C -4 -26, -12 -14, 0 0 Z", fill: C.ember }),
el("path", { d: "M0 -4 C 6 -12, 2 -18, 0 -24 C -2 -18, -6 -12, 0 -4 Z", fill: C.yellow }),
el("path", { d: "M0 0 C 11 -12, 4 -22, 0 -32 C -4 -22, -11 -12, 0 0 Z", fill: C.ember }),
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: C.yellow }),
);
}
function silhouette(methodKey, x, y, scale = 1, fill = C.ink) {
const g = el("g", { transform: `translate(${x} ${y}) scale(${scale})`, fill });
const SIL_SCALE = 2.6;
const SIL_X = 280 - 32 * SIL_SCALE; // silhouette 64-box centred horizontally
const SIL_Y = 34;
function silhouette(methodKey) {
const g = el("g", { transform: `translate(${SIL_X} ${SIL_Y}) scale(${SIL_SCALE})`, fill: C.ink2 });
for (const d of BREW_SILHOUETTES[methodKey] ?? []) g.append(el("path", { d }));
return g;
}
/** Convert silhouette-local (0..64) coords to scene coords. */
const sil = (lx, ly) => [SIL_X + lx * SIL_SCALE, SIL_Y + ly * SIL_SCALE];
// Where each brewer holds its slurry / receives its water, in silhouette-local coords.
const BOWL = {
"french-press": [32, 34], aeropress: [32, 34], clever: [30, 22], "hario-switch": [32, 20],
"cold-brew": [32, 32], cupping: [32, 36], v60: [31, 20], chemex: [32, 16], kalita: [32, 22],
batch: [37, 33], percolator: [31, 30], espresso: [32, 38], moka: [32, 16],
};
const STAGES = [
{ name: "Green", color: C.green },
{ name: "Yellow", color: C.yellow },
{ name: "Maillard", color: C.tan },
{ name: "Development", color: C.brown },
{ name: "Dark", color: C.dark },
];
// ── Scene builders ─────────────────────────────────────────────────────────
// Each returns children for a 560×300 viewBox.
// ── Scene builders (each returns children for the 560×300 canvas) ─────────
const SCENES = {
beanAnatomy() {
const cx = 180, cy = 145;
return [
el("ellipse", { cx: 220, cy: 150, rx: 120, ry: 105, fill: C.green, class: "ac-breathe" }),
el("ellipse", { cx: 220, cy: 150, rx: 104, ry: 90, fill: "#8ea06f" }),
el("path", { d: "M220 55 C 250 110, 250 190, 220 245 C 200 190, 200 110, 220 55 Z", fill: "#6a7a50" }),
...[[150, 95], [280, 110], [180, 210], [265, 195], [225, 150]].map(([x, y], i) =>
el("circle", { cx: x, cy: y, r: 5, fill: C.water, class: `ac-pulse ac-delay-${i % 3}` }),
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: C.green, class: "ac-breathe" }),
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: "#8ea06f" }),
el("path", { d: `M${cx} ${cy - 86} C ${cx + 27} ${cy - 36}, ${cx + 27} ${cy + 36}, ${cx} ${cy + 86} C ${cx - 18} ${cy + 36}, ${cx - 18} ${cy - 36}, ${cx} ${cy - 86} Z`, fill: "#6a7a50" }),
...[[cx - 55, cy - 45, 0], [cx + 48, cy - 30, 1], [cx - 30, cy + 50, 2], [cx + 40, cy + 42, 0], [cx + 4, cy - 4, 1]].map(([x, y, d]) =>
el("circle", { cx: x, cy: y, r: 5, fill: C.water, class: `ac-pulse ac-delay-${d}` }),
),
el("line", { x1: 330, y1: 78, x2: 402, y2: 78, stroke: C.line, "stroke-width": 1.5 }),
text(408, 82, "silverskin (chaff)", 13, C.ink2, "start"),
el("line", { x1: 336, y1: 150, x2: 402, y2: 150, stroke: C.line, "stroke-width": 1.5 }),
text(408, 154, "endosperm — the pantry", 13, C.ink2, "start"),
el("line", { x1: 300, y1: 226, x2: 402, y2: 226, stroke: C.line, "stroke-width": 1.5 }),
text(408, 230, "≈11% water", 13, C.water, "start"),
text(220, 285, "the seed of a coffee cherry", 13, C.ink2),
// Legend, leader lines rooted on the feature they name
el("line", { x1: cx + 96, y1: cy - 62, x2: 366, y2: 70, stroke: C.line, "stroke-width": 1.5 }),
text(374, 74, "silverskin — the chaff", 12.5, C.ink2, "start"),
el("line", { x1: cx + 88, y1: cy, x2: 366, y2: 140, stroke: C.line, "stroke-width": 1.5 }),
text(374, 136, "endosperm — the pantry:", 12.5, C.ink2, "start"),
text(374, 154, "sugars, acids, proteins", 12.5, C.ink2, "start"),
el("line", { x1: cx + 60, y1: cy + 52, x2: 366, y2: 212, stroke: C.line, "stroke-width": 1.5 }),
text(374, 210, "≈11% water", 12.5, C.water, "start", "700"),
text(374, 228, "(it all must leave)", 11.5, C.ink2, "start"),
caption("the seed of a coffee cherry, in cross-section"),
];
},
composition(_, slideEl) {
composition() {
const parts = [
["Cellulose", 48, C.ink2],
["Sugars", 12, C.tan],
["Water", 11, C.water],
["Lipids", 12, C.yellow],
["Acids", 8, C.ember],
["Water", 11, C.water],
["Proteins", 9, C.brown],
["Acids", 8, C.ember],
];
let y = 60;
const out = [bean(120, 150, 2.2, C.green)];
for (const [name, pct, color] of parts) {
const out = [bean(110, 140, 1.9, C.green, { class: "ac-breathe" })];
parts.forEach(([name, pct, color], i) => {
const y = 52 + i * 34;
const w = pct * 4.4;
out.push(
el("rect", { x: 260, y, width: 0, height: 22, rx: 4, fill: color, class: "ac-grow-w", style: `--w:${pct * 4.6}px` }),
text(252, y + 16, name, 12.5, C.ink2, "end"),
text(268 + pct * 4.6, y + 16, `${pct}%`, 12, C.ink2, "start"),
text(248, y + 16, name, 12.5, C.ink2, "end"),
el("rect", { x: 258, y, width: 0, height: 21, rx: 4, fill: color, class: `ac-grow-w ac-delay-${Math.min(i, 4)}`, style: `--w:${w}px` }),
text(266 + w, y + 16, `${pct}%`, 12, C.ink2, "start"),
);
y += 34;
}
return out;
},
densityScale() {
return [
el("path", { d: "M110 220 q 170 44 340 0", fill: "none", stroke: C.line, "stroke-width": 3 }),
bean(140, 170, 1.15, "#93a375"), text(140, 250, "soft · low-grown", 12.5),
bean(280, 190, 1.0, C.green), text(280, 262, "typical", 12.5),
bean(420, 170, 0.85, "#5d6b45"), text(420, 250, "dense · high-grown", 12.5),
el("g", { class: "ac-slide-x" },
el("path", { d: "M0 0 l 10 18 h -20 Z", transform: "translate(140 130)", fill: C.ember }),
),
text(280, 60, "denser beans need more energy, delivered more patiently", 13, C.ink2),
text(280, 82, "moisture must boil off before browning can begin", 12.5, C.water),
];
},
stageRow({ stage = 0 }) {
const out = [];
STAGES.forEach((s, i) => {
const x = 90 + i * 96;
out.push(
bean(x, 140, 0.95 + (i >= 3 ? 0.12 : 0), s.color, { class: i === stage ? "ac-breathe" : "", crack: i === 3 }),
text(x, 210, s.name, 12.5, i === stage ? C.ember : C.ink2, "middle", i === stage ? "700" : "400"),
);
if (i === stage)
out.push(el("circle", { cx: x, cy: 140, r: 48, fill: "none", stroke: C.ember, "stroke-width": 2, "stroke-dasharray": "5 5", class: "ac-spin" }));
});
out.push(el("path", { d: "M60 246 H 500", stroke: C.line, "stroke-width": 2 }),
el("path", { d: "M60 246 H 500", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }),
text(280, 275, "drying → browning → development", 12.5));
out.push(caption("half scaffolding, half flavor pantry — plus the water"));
return out;
},
charge() {
return [
el("rect", { x: 70, y: 70, width: 200, height: 140, rx: 70, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
// Drum with tumbling beans
el("circle", { cx: 150, cy: 138, r: 76, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
el("g", { class: "ac-tumble" },
bean(140, 130, 0.55, C.green), bean(180, 150, 0.55, C.green), bean(160, 170, 0.55, "#8ea06f"),
bean(200, 120, 0.55, C.green)),
flame(170, 250, 1),
el("rect", { x: 240, y: 128, width: 46, height: 8, rx: 4, fill: C.ink2 }),
text(263, 120, "probe", 11.5),
el("path", { d: "M330 90 L 385 200 Q 397 222 412 200 L 470 110", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }),
el("circle", { cx: 399, cy: 212, r: 5, fill: C.ember, class: "ac-pulse" }),
text(399, 245, "turning point", 12.5, C.ember, "middle", "700"),
text(399, 264, "your machine's thermal fingerprint", 12),
bean(126, 118, 0.5, C.green), bean(172, 132, 0.5, C.green),
bean(140, 162, 0.5, "#8ea06f"), bean(182, 168, 0.5, C.green)),
flame(150, 250, 1),
el("line", { x1: 254, y1: 120, x2: 205, y2: 132, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round" }),
el("circle", { cx: 205, cy: 132, r: 4, fill: C.ink }),
text(260, 116, "probe", 11.5, C.ink2, "start"),
// Temperature dip-and-turn, drawn live
el("path", { d: "M310 230 H 520 M310 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
el("path", { d: "M316 78 L 372 196 Q 384 216 398 196 L 512 92", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }),
el("circle", { cx: 385, cy: 208, r: 5, fill: C.ember, class: "ac-pulse" }),
text(415, 246, "turning point", 12.5, C.ember, "middle", "700"),
caption("the probe reads the beans — where the line turns is your machine's fingerprint"),
];
},
steam() {
return [
flame(280, 292, 1.3),
bean(280, 160, 2, C.green, { class: "ac-breathe" }),
steamPaths(280, 92, 3),
text(120, 90, "energy in ", 13, C.ember, "middle", "700"),
el("path", { d: "M150 110 q 40 30 78 34", fill: "none", stroke: C.ember, "stroke-width": 2, "marker-end": "" }),
text(452, 78, "→ water out", 13, C.water, "middle", "700"),
text(280, 270, "evaporation soaks up heat — the endothermic phase", 12.5),
flame(170, 262, 1.1),
bean(170, 150, 1.9, C.green, { class: "ac-dry-shift" }),
steamWisps(170, 92, 3),
text(170, 250, "water out ↑ · heat in ", 12, C.ink2),
// Contained thermometer: the slow, stubborn climb
el("rect", { x: 392, y: 64, width: 18, height: 140, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("circle", { cx: 401, cy: 216, r: 14, fill: C.ember }),
el("rect", { x: 396, y: 74, width: 10, height: 126, rx: 5, fill: C.ember, class: "ac-fill-up" }),
text(430, 140, "temperature climbs", 12, C.ink2, "start"),
text(430, 158, "stubbornly — the", 12, C.ink2, "start"),
text(430, 176, "endothermic phase", 12, C.ember, "start", "700"),
text(430, 216, "4351% of the roast", 12, C.ink2, "start"),
caption("evaporation soaks up energy while the bean fades green → yellow"),
];
},
phaseBar({ highlight }) {
const phases = [
["drying", 47, C.yellow, "4351%"],
["maillard", 36, C.tan, "3339%"],
["development", 17, C.brown, "1220%"],
];
let x = 60;
const out = [text(280, 70, "one roast, three phases — share of total time", 13)];
for (const [key, pct, color, band] of phases) {
const w = pct * 4.4;
const active = key === highlight;
out.push(
el("rect", { x, y: 120, width: w, height: 54, rx: 6, fill: color, opacity: active ? 1 : 0.35, class: active ? "ac-grow-w" : "", style: `--w:${w}px` }),
text(x + w / 2, 152, key === "maillard" ? "Maillard" : key[0].toUpperCase() + key.slice(1), 13, active ? C.paper : C.ink2, "middle", active ? "700" : "400"),
text(x + w / 2, 205, band, 12.5, active ? C.ember : C.ink2, "middle", active ? "700" : "400"),
);
x += w + 4;
}
out.push(text(280, 250, "the worksheet's sanity checks are exactly these bands", 12.5));
return out;
},
maillard() {
return [
bean(180, 150, 2, C.yellow, { class: "ac-maillard-shift" }),
...[0, 1, 2, 3, 4].map((i) =>
el("circle", { cx: 300 + i * 34, cy: 120 - (i % 3) * 22, r: 4, fill: i % 2 ? C.tan : C.ember, class: `ac-sparkle ac-delay-${i % 3}` }),
),
text(390, 190, "amino acids + sugars", 13, C.ink2),
text(390, 210, "→ hundreds of aroma compounds", 13, C.ember),
text(180, 268, "≈150 °C — the browning begins", 12.5),
];
},
expand() {
return [
bean(230, 150, 2, C.tan, { class: "ac-breathe" }),
bean(160, 148, 1.9, C.yellow, { class: "ac-maillard-shift ac-breathe" }),
// Chaff flaking off the bean, drifting up-right
...[0, 1, 2].map((i) =>
el("path", { d: `M${300 + i * 30} ${100 + i * 26} q 14 6 22 -6`, stroke: C.yellow, "stroke-width": 3, fill: "none", class: `ac-drift ac-delay-${i}` }),
el("path", {
d: `M${208 + i * 6} ${104 - i * 10} q 10 4 16 -4`,
stroke: C.yellow, "stroke-width": 3, fill: "none",
class: `ac-drift ac-delay-${i}`, "stroke-linecap": "round",
}),
),
text(392, 90, "chaff lifts away", 12.5, C.ink2, "start"),
text(230, 262, "steam + CO₂ swell the bean — airflow carries the chaff out", 12.5),
// Aroma compounds appearing
...[[320, 92, 0], [356, 120, 1], [336, 156, 2], [386, 88, 1], [372, 178, 0], [402, 140, 2]].map(([x, y, d]) =>
el("circle", { cx: x, cy: y, r: 4.5, fill: d === 1 ? C.ember : C.tan, class: `ac-sparkle ac-delay-${d}` }),
),
text(452, 116, "amino acids", 12.5, C.ink2, "start"),
text(452, 134, "+ sugars →", 12.5, C.ink2, "start"),
text(452, 152, "aroma", 12.5, C.ember, "start", "700"),
text(160, 250, "≈150 °C — browning begins", 12),
caption("the bean swells, chaff lifts away, hundreds of new compounds appear"),
];
},
curve({ shape = "smooth", focus = "all" }) {
const phases = [
el("rect", { x: 60, y: 60, width: 150, height: 170, fill: C.yellow, opacity: focus === "all" || focus === "drying" ? 0.14 : 0.05 }),
el("rect", { x: 210, y: 60, width: 160, height: 170, fill: C.tan, opacity: focus === "all" || focus === "maillard" ? 0.2 : 0.05 }),
el("rect", { x: 370, y: 60, width: 130, height: 170, fill: C.brown, opacity: focus === "all" || focus === "development" ? 0.2 : 0.05 }),
];
return [
...phases,
el("path", { d: "M60 230 H 500 M60 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
el("path", { d: "M60 100 L 92 200 Q 100 218 112 200 C 180 105, 300 92, 500 68", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }),
text(135, 250, "drying", 12), text(290, 250, "Maillard", 12), text(435, 250, "development", 12),
el("circle", { cx: 383, cy: 76, r: 5, fill: C.ink, class: "ac-pulse" }),
text(383, 58, "first crack", 12, C.ink, "middle", "700"),
text(280, 285, "bean temperature — the story of the roast", 12.5),
];
},
crack({ phase }) {
crack({ phase = "during" } = {}) {
const during = phase === "during";
return [
bean(230, 150, 2.1, during ? C.brown : C.tan, { crack: true, class: during ? "ac-shake" : "" }),
...(during
? [0, 1, 2].map((i) =>
el("circle", { cx: 230, cy: 150, r: 60, fill: "none", stroke: C.ember, "stroke-width": 2, class: `ac-ring ac-delay-${i}` }),
)
: [
...[0, 1, 2, 3].map((i) =>
el("path", { d: `M${196 + i * 24} ${96 - (i % 2) * 10} l 6 -14`, stroke: C.ink2, "stroke-width": 2.5, class: `ac-pulse ac-delay-${i % 3}` }),
),
]),
text(400, 120, during ? "energy released —" : "steam + CO₂", 13, during ? C.ember : C.water, "middle", "700"),
text(400, 140, during ? "the exotherm" : "≈8 bar inside", 12.5),
text(230, 272, during ? "cut heat before this moment, gently" : "a pressure vessel about to fail", 12.5),
bean(280, 140, 2, during ? C.brown : C.tan, { crack: true, class: during ? "ac-shake" : "" }),
...[0, 1, 2].map((i) =>
el("circle", { cx: 280, cy: 140, r: 58, fill: "none", stroke: C.ember, "stroke-width": 2, class: `ac-ring ac-delay-${i}` }),
),
// Pressure pushing outward (left), heat released (right)
...[[-118, "≈8 bar", "of steam inside"], [118, "the exotherm —", "heat released"]].map(([dx, l1, l2], side) =>
el("g", {},
text(280 + dx, 118, l1, 12.5, side ? C.ember : C.water, "middle", "700"),
text(280 + dx, 136, l2, 12, C.ink2),
),
),
...[0, 1, 2].map((i) =>
el("path", { d: `M${332 + i * 12} 156 l 12 8`, stroke: C.ember, "stroke-width": 2.5, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }),
),
text(280, 244, "≈196 °C — the pop, like popcorn", 12.5, C.ink, "middle", "700"),
caption("the heat cut happens BEFORE this moment — gently, a minute upstream"),
];
},
dtr() {
return [
el("rect", { x: 60, y: 120, width: 440, height: 40, rx: 8, fill: C.line }),
el("rect", { x: 60, y: 120, width: 360, height: 40, rx: 8, fill: C.tan, opacity: 0.5 }),
el("rect", { x: 420, y: 120, width: 0, height: 40, rx: 8, fill: C.ember, class: "ac-grow-w", style: "--w:80px" }),
el("line", { x1: 420, y1: 100, x2: 420, y2: 180, stroke: C.ink, "stroke-width": 2, "stroke-dasharray": "4 3" }),
text(420, 92, "first crack", 12, C.ink, "middle", "700"),
text(460, 145, "DTR", 13, C.paper, "middle", "700"),
text(280, 210, "development ÷ total time = 1220%", 13.5, C.ember, "middle", "700"),
text(280, 235, "the strongest single lever on the cup", 12.5),
text(280, 60, "one roast, on a timeline", 12.5),
el("rect", { x: 70, y: 110, width: 420, height: 44, rx: 8, fill: C.line }),
el("rect", { x: 70, y: 110, width: 280, height: 44, rx: 8, fill: C.tan, opacity: 0.45 }),
el("rect", { x: 350, y: 110, width: 0, height: 44, rx: 8, fill: C.ember, class: "ac-grow-w", style: "--w:140px" }),
el("line", { x1: 350, y1: 92, x2: 350, y2: 172, stroke: C.ink, "stroke-width": 2, "stroke-dasharray": "4 3" }),
text(350, 84, "first crack", 12, C.ink, "middle", "700"),
text(210, 138, "drying + Maillard", 12.5, C.ink2),
text(420, 138, "development", 12.5, C.paper, "middle", "700"),
text(70, 172, "0:00", 11.5, C.ink2, "start"),
text(490, 172, "drop", 11.5, C.ink2, "end"),
text(280, 214, "development ÷ total time = 1220%", 14, C.ember, "middle", "700"),
caption("the strongest single lever on the cup — the app computes it from every log"),
];
},
weightLoss() {
return [
bean(170, 130, 1.5, C.green), text(170, 205, "250 g green", 13),
el("path", { d: "M240 130 h 70", stroke: C.ink2, "stroke-width": 2, class: "ac-draw" }),
el("path", { d: "M304 124 l 10 6 l -10 6 Z", fill: C.ink2 }),
bean(380, 130, 1.62, C.brown, { crack: true }), text(380, 205, "≈220 g roasted", 13),
text(280, 250, "1113% for a light roast — the honest number", 13, C.ember, "middle", "700"),
text(280, 272, "time, temperature and development, measured by a kitchen scale", 12),
bean(150, 128, 1.5, C.green),
text(150, 208, "250 g green", 13, C.ink, "middle", "700"),
el("line", { x1: 226, y1: 128, x2: 302, y2: 128, stroke: C.ink2, "stroke-width": 2.5, class: "ac-draw" }),
el("path", { d: "M300 121 l 12 7 l -12 7 Z", fill: C.ink2 }),
bean(390, 128, 1.62, C.brown, { crack: true }),
text(390, 208, "≈220 g roasted", 13, C.ink, "middle", "700"),
text(280, 244, "1113% for a light roast", 13.5, C.ember, "middle", "700"),
caption("time, temperature and development — measured by a kitchen scale"),
];
},
ror({ shape }) {
const paths = {
smooth: "M60 90 C 150 130, 300 170, 500 195",
crash: "M60 90 C 150 130, 240 150, 300 160 C 340 168, 350 225, 380 228 C 405 230, 415 175, 460 185 L 500 195",
stall: "M60 90 C 140 130, 220 165, 280 178 L 420 178 C 460 178, 480 188, 500 192",
};
const notes = {
smooth: ["a steady, always-falling decline", C.pass],
crash: ["crash… then the flick — bakes sweetness, adds roast", C.fail],
stall: ["the stall: flat means baking", C.fail],
};
const [note, color] = notes[shape];
curve({ focus = "all" } = {}) {
const band = (x, w, color, key) =>
el("rect", { x, y: 62, width: w, height: 168, fill: color, opacity: focus === "all" || focus === key ? 0.16 : 0.05 });
return [
el("path", { d: "M60 230 H 500 M60 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
text(46, 90, "RoR", 11.5, C.ink2, "end"),
el("path", { d: paths.smooth, fill: "none", stroke: C.line, "stroke-width": 2, "stroke-dasharray": "5 4" }),
el("path", { d: paths[shape], fill: "none", stroke: color, "stroke-width": 3, class: "ac-draw" }),
shape === "crash" ? el("circle", { cx: 380, cy: 228, r: 6, fill: C.fail, class: "ac-pulse" }) : el("g"),
shape === "stall" ? el("rect", { x: 280, y: 170, width: 140, height: 16, fill: C.fail, opacity: 0.12 }) : el("g"),
text(280, 268, note, 13, color, "middle", "700"),
band(60, 150, C.yellow, "drying"),
band(210, 158, C.tan, "maillard"),
band(368, 132, C.brown, "development"),
el("path", { d: "M60 230 H 500 M60 230 V 62", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
el("path", { d: "M64 96 L 96 198 Q 104 216 116 198 C 180 108, 300 96, 494 72", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }),
el("circle", { cx: 100, cy: 207, r: 4.5, fill: C.ember }),
text(100, 250, "TP", 11.5, C.ink2),
el("circle", { cx: 375, cy: 79, r: 5, fill: C.ink, class: "ac-pulse" }),
text(375, 56, "first crack", 12, C.ink, "middle", "700"),
text(135, 250, "drying", 12), text(289, 250, "Maillard", 12), text(434, 250, "development", 12),
caption("bean temperature — dip, turn, climb, crack, drop: the story of the roast"),
];
},
symptom({ kind }) {
if (kind === "surface")
return [
bean(180, 140, 1.7, C.brown),
el("circle", { cx: 180, cy: 84, r: 9, fill: C.dark, class: "ac-pulse" }),
el("circle", { cx: 180, cy: 196, r: 9, fill: C.dark, class: "ac-pulse ac-delay-1" }),
text(180, 240, "tipping — ends scorched", 12.5),
bean(390, 140, 1.7, C.brown),
el("ellipse", { cx: 372, cy: 132, rx: 16, ry: 24, fill: C.dark, opacity: 0.8, class: "ac-pulse ac-delay-2" }),
text(390, 240, "scorching — drum-face burn", 12.5),
text(280, 278, "machine settings, not timing: lower charge, more airflow, smaller batch", 12),
];
const fast = kind === "fast";
ror() {
return [
bean(160, 140, 1.9, fast ? "#c9a76a" : C.dark, { crack: fast }),
text(160, 240, fast ? "pale centre, papery cup" : "florals spent, savory cup", 12.5),
el("path", { d: "M300 140 h 110", stroke: C.ink2, "stroke-width": 2 }),
el("path", { d: "M404 134 l 10 6 l -10 6 Z", fill: C.ink2 }),
text(430, 120, "one change:", 12.5, C.ink2, "start"),
text(430, 146, fast ? "FC +0:30" : "FC 0:30", 17, C.ember, "start", "700"),
text(280, 278, fast ? "the crack came before the inside was ready" : "delicate aromatics are volatile — they left before the drop", 12),
el("path", { d: "M60 230 H 500 M60 230 V 62", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
text(52, 76, "RoR", 11.5, C.ink2, "end"),
// The ideal: steady decline
el("path", { d: "M64 92 C 160 128, 300 168, 494 194", fill: "none", stroke: C.pass, "stroke-width": 2.5, "stroke-dasharray": "6 5" }),
text(150, 106, "ideal: always falling", 12, C.pass, "middle", "700"),
// The failure: crash, then flick
el("path", { d: "M64 92 C 150 130, 230 152, 292 162 C 330 168, 336 224, 366 226 C 392 228, 398 176, 442 184 L 494 194", fill: "none", stroke: C.fail, "stroke-width": 3, class: "ac-draw" }),
el("circle", { cx: 352, cy: 224, r: 5.5, fill: C.fail, class: "ac-pulse" }),
text(340, 252, "crash", 12, C.fail, "middle", "700"),
el("circle", { cx: 420, cy: 179, r: 5.5, fill: C.fail, class: "ac-pulse ac-delay-1" }),
text(432, 160, "flick", 12, C.fail, "middle", "700"),
// The stall
el("line", { x1: 200, y1: 148, x2: 300, y2: 148, stroke: C.warnColor ?? "#a8741a", "stroke-width": 3, "stroke-dasharray": "2 4" }),
text(250, 136, "stall = baking", 12, "#a8741a", "middle", "700"),
caption("crash bakes out sweetness · flick tastes roasty · the fix is always upstream"),
];
},
oneChange() {
const cards = [
["batch 1", "baseline", false],
["batch 2", "FC +0:30", false],
["batch 3", "dev +0:15", true],
];
return [
...[0, 1, 2].map((i) =>
el("g", {},
el("rect", { x: 90 + i * 140, y: 90, width: 110, height: 74, rx: 8, fill: i === 2 ? C.emberSoft : C.paper, stroke: i === 2 ? C.ember : C.line, "stroke-width": 1.6 }),
text(145 + i * 140, 118, `batch ${i + 1}`, 12.5, C.ink2),
text(145 + i * 140, 144, i === 0 ? "baseline" : i === 1 ? "FC +0:30" : "dev +0:15", 13, i === 2 ? C.ember : C.ink, "middle", "700"),
...cards.map(([label, change, active], i) =>
el("g", { class: active ? "ac-pulse-soft" : "" },
el("rect", { x: 84 + i * 146, y: 78, width: 116, height: 76, rx: 8, fill: active ? C.emberSoft : C.paper, stroke: active ? C.ember : C.line, "stroke-width": 1.6 }),
text(142 + i * 146, 106, label, 12.5, C.ink2),
text(142 + i * 146, 134, change, 13.5, active ? C.ember : C.ink, "middle", "700"),
),
),
el("path", { d: "M200 127 h 26 M340 127 h 26", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }),
text(280, 210, "one variable per batch — the only experiment you can afford", 13, C.ink, "middle", "700"),
text(280, 234, "the app carries your note into the next plan's ± Refine", 12.5),
el("path", { d: "M202 116 h 24 M348 116 h 24", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }),
text(280, 196, "papery → FC +0:30 · savory → FC 0:30 · sharp → dev +0:15", 12.5, C.ink),
text(280, 224, "one variable per batch — the only experiment you can afford", 13, C.ember, "middle", "700"),
caption("your note rides into the next plan's ± Refine automatically"),
];
},
// ── Brewing scenes ──
dissolve() {
const order = [
["acids — bright, sour", C.yellow, 0],
["sugars — sweet", C.tan, 1],
["bitters — heavy, dry", C.dark, 2],
];
return [
el("circle", { cx: 150, cy: 150, r: 52, fill: C.brown }),
...[...Array(9)].map((_, i) =>
el("circle", { cx: 150 + Math.cos((i / 9) * 6.28) * 30, cy: 150 + Math.sin((i / 9) * 6.28) * 30, r: 5, fill: [C.yellow, C.tan, C.dark][i % 3], class: `ac-dissolve ac-delay-${i % 3}` }),
el("circle", { cx: 150, cy: 140, r: 52, fill: C.brown }),
text(150, 218, "ground coffee", 12, C.ink2),
...[0, 1, 2, 3, 4, 5].map((i) =>
el("circle", {
cx: 196, cy: 112 + (i % 3) * 26, r: 5,
fill: [C.yellow, C.tan, C.dark][i % 3],
class: `ac-dissolve ac-delay-${i % 3}`,
}),
),
...order.map(([label, color, i]) =>
...[
["1. acids — bright, sour", C.yellow],
["2. sugars — sweet", C.tan],
["3. bitters — heavy, dry", C.dark],
].map(([label, color], i) =>
el("g", {},
el("circle", { cx: 300, cy: 100 + i * 46, r: 8, fill: color }),
text(320, 105 + i * 46, `${i + 1}. ${label}`, 13, C.ink2, "start"),
el("circle", { cx: 312, cy: 96 + i * 44, r: 8, fill: color }),
text(330, 101 + i * 44, label, 13, C.ink2, "start"),
),
),
text(280, 262, "hot water dissolves them in this order — brewing is choosing when to stop", 12.5),
caption("hot water dissolves them in this order — brewing is choosing when to stop"),
];
},
yieldMap() {
return [
el("rect", { x: 90, y: 70, width: 380, height: 150, rx: 8, fill: C.paper, stroke: C.line }),
el("rect", { x: 218, y: 70, width: 124, height: 150, fill: C.pass, opacity: 0.14 }),
text(280, 60, "extraction yield →", 12.5),
text(150, 150, "sour\nthin", 13, C.fail),
text(280, 140, "sweet spot", 13.5, C.pass, "middle", "700"),
text(280, 160, "1822%", 13, C.pass, "middle", "700"),
text(415, 150, "bitter\ndrying", 13, C.fail),
el("circle", { cx: 280, cy: 190, r: 6, fill: C.ember, class: "ac-slide-x" }),
text(280, 250, "strength = how much coffee is in the water · extraction = how much you took from the grounds", 11.5),
text(150, 92, "sour · thin", 12.5, C.fail, "middle", "700"),
text(280, 78, "sweet spot", 13, C.pass, "middle", "700"),
text(280, 96, "1822%", 13, C.pass, "middle", "700"),
text(412, 92, "bitter · drying", 12.5, C.fail, "middle", "700"),
el("rect", { x: 90, y: 112, width: 380, height: 56, rx: 10, fill: C.paper, stroke: C.line }),
el("rect", { x: 90, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
el("rect", { x: 342, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
el("rect", { x: 218, y: 112, width: 124, height: 56, fill: C.pass, opacity: 0.16 }),
el("circle", { cx: 280, cy: 140, r: 7, fill: C.ember, class: "ac-slide-x" }),
text(280, 194, "extraction yield →", 12, C.ink2),
text(280, 230, "strength = coffee in the water", 12.5, C.ink2),
text(280, 248, "extraction = what you took from the grounds", 12.5, C.ink2),
caption("aim the dot at the middle: every dial below exists to move it"),
];
},
grind() {
const pile = (x, label, sub, circles) => {
const g = el("g", {});
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: C.brown }));
g.append(text(x, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
return g;
};
return [
...[["coarse", 26, 3, 130], ["medium", 15, 8, 280], ["fine", 8, 18, 430]].map(([name, r, n, x]) => {
const g = el("g", {});
for (let i = 0; i < n; i++)
g.append(el("circle", { cx: x + Math.cos((i / n) * 6.28) * (n > 4 ? 34 : 14) * (n > 10 ? 1.2 : 1), cy: 130 + Math.sin((i / n) * 6.28) * (n > 4 ? 30 : 12), r, fill: C.brown }));
g.append(text(x, 220, name, 13, C.ink2), text(x, 240, name === "fine" ? "huge surface, slow flow" : name === "coarse" ? "small surface, fast flow" : "the middle path", 11.5));
return g;
}),
text(280, 66, "same mass of coffee — very different surface area", 13),
text(280, 278, "grind is the master dial: it moves extraction and contact time together", 12.5),
text(280, 62, "the same 20 grams of coffee", 12.5),
pile(130, "coarse", "less surface · fast flow", [[-16, -10, 19], [16, -8, 19], [0, 18, 19]]),
pile(280, "medium", "the middle path", [[-24, -14, 10], [0, -18, 10], [24, -14, 10], [-14, 4, 10], [12, 4, 10], [-2, 22, 10], [22, 20, 10]]),
pile(430, "fine", "more surface · slow flow", [...Array(16)].map((_, i) => [((i % 4) - 1.5) * 16, (Math.floor(i / 4) - 1.5) * 15, 5])),
caption("grind is the master dial — it moves extraction and contact time together"),
];
},
tempTime() {
return [
el("g", {},
el("rect", { x: 100, y: 84, width: 18, height: 120, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("circle", { cx: 109, cy: 214, r: 15, fill: C.ember }),
el("rect", { x: 103, y: 110, width: 12, height: 100, rx: 6, fill: C.ember, class: "ac-grow-h" }),
text(109, 250, "9096 °C", 12.5)),
el("g", {},
el("circle", { cx: 280, cy: 145, r: 55, fill: "none", stroke: C.ink2, "stroke-width": 3 }),
el("line", { x1: 280, y1: 145, x2: 280, y2: 104, stroke: C.ember, "stroke-width": 3, class: "ac-spin-slow", "transform-origin": "280px 145px" }),
text(280, 250, "time = how far down the list", 12.5)),
el("g", {},
bean(420, 118, 0.7, C.brown), text(448, 122, "1 :", 15, C.ink, "start", "700"),
...[0, 1, 2].map((i) => el("circle", { cx: 478 + i * 16, cy: 118, r: 6, fill: C.water, class: `ac-pulse ac-delay-${i}` })),
text(452, 250, "ratio 1:151:17", 12.5, C.ink2)),
// Temperature: contained thermometer
el("rect", { x: 108, y: 68, width: 18, height: 118, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("circle", { cx: 117, cy: 198, r: 13, fill: C.ember }),
el("rect", { x: 112, y: 76, width: 10, height: 106, rx: 5, fill: C.ember, class: "ac-fill-up" }),
text(117, 240, "9096 °C", 12.5, C.ink, "middle", "700"),
// Time: a gauge whose needle sweeps
el("path", { d: "M220 190 A 62 62 0 0 1 340 190", fill: "none", stroke: C.line, "stroke-width": 10, "stroke-linecap": "round" }),
el("line", { x1: 280, y1: 190, x2: 280, y2: 138, stroke: C.ink, "stroke-width": 4, class: "ac-needle", "transform-origin": "280px 190px" }),
el("circle", { cx: 280, cy: 190, r: 8, fill: C.ink }),
text(280, 240, "time = how far down the list", 12.5, C.ink, "middle", "700"),
// Ratio: one bean to many drops
bean(420, 110, 0.62, C.brown),
text(446, 116, "1 :", 15, C.ink, "start", "700"),
...[0, 1, 2].map((i) =>
el("path", { d: `M${480 + i * 18} 104 c 5 8 5 14 0 18 c -5 -4 -5 -10 0 -18 Z`, fill: C.water, class: `ac-pulse ac-delay-${i}` }),
),
text(466, 240, "1:151:17", 12.5, C.ink, "middle", "700"),
caption("three dials on one cup — and grind, from the previous slide, is the fourth"),
];
},
tasteDial() {
return [
el("path", { d: "M120 200 A 160 160 0 0 1 440 200", fill: "none", stroke: C.line, "stroke-width": 16, "stroke-linecap": "round" }),
el("path", { d: "M120 200 A 160 160 0 0 1 245 62", fill: "none", stroke: C.fail, "stroke-width": 16, "stroke-linecap": "round", opacity: 0.55 }),
el("path", { d: "M320 64 A 160 160 0 0 1 440 200", fill: "none", stroke: C.fail, "stroke-width": 16, "stroke-linecap": "round", opacity: 0.55 }),
text(120, 240, "sour · sharp", 13, C.fail),
text(280, 52, "sweet & balanced", 13, C.pass, "middle", "700"),
text(440, 240, "bitter · drying", 13, C.fail),
el("line", { x1: 280, y1: 200, x2: 280, y2: 90, stroke: C.ink, "stroke-width": 4, class: "ac-dial", "transform-origin": "280px 200px" }),
el("circle", { cx: 280, cy: 200, r: 10, fill: C.ink }),
text(280, 268, "extract more ← grind finer · hotter · longer → extract less", 12.5),
el("path", { d: "M130 198 A 156 156 0 0 1 430 198", fill: "none", stroke: C.line, "stroke-width": 15, "stroke-linecap": "round" }),
el("path", { d: "M130 198 A 156 156 0 0 1 232 72", fill: "none", stroke: C.fail, "stroke-width": 15, "stroke-linecap": "round", opacity: 0.5 }),
el("path", { d: "M328 72 A 156 156 0 0 1 430 198", fill: "none", stroke: C.fail, "stroke-width": 15, "stroke-linecap": "round", opacity: 0.5 }),
text(126, 232, "sour · sharp", 12.5, C.fail, "middle", "700"),
text(280, 48, "sweet & balanced", 13, C.pass, "middle", "700"),
text(434, 232, "bitter · drying", 12.5, C.fail, "middle", "700"),
el("line", { x1: 280, y1: 198, x2: 280, y2: 92, stroke: C.ink, "stroke-width": 4, class: "ac-dial", "transform-origin": "280px 198px" }),
el("circle", { cx: 280, cy: 198, r: 10, fill: C.ink }),
text(280, 246, "sour? extract more · bitter? extract less", 13, C.ink, "middle", "700"),
caption("finer, hotter, longer ← the dial → coarser, cooler, shorter"),
];
},
bloom() {
const [bx, by] = [280, 118];
return [
silhouette("v60", 210, 60, 2.2, C.ink2),
silhouette("v60"),
// CO₂ bubbles rising out of the cone
...[0, 1, 2, 3].map((i) =>
el("circle", { cx: 250 + (i % 2) * 30, cy: 118 - i * 4, r: 5 - i, fill: C.tan, class: `ac-rise ac-delay-${i}` }),
el("circle", { cx: bx - 18 + i * 12, cy: by - i * 3, r: 4 - i * 0.5, fill: C.tan, class: `ac-rise ac-delay-${i}` }),
),
text(430, 110, "CO₂ escaping", 12.5, C.tan, "start"),
text(430, 132, "the bed swells & bubbles", 12.5, C.ink2, "start"),
text(280, 262, "2× the coffee's weight in water · wait 3045 s", 13, C.ember, "middle", "700"),
text(430, 96, "CO₂ escaping", 12.5, C.tan, "start", "700"),
text(430, 114, "the bed swells", 12.5, C.ink2, "start"),
text(430, 132, "and bubbles", 12.5, C.ink2, "start"),
text(130, 96, "2× the coffee's", 12.5, C.ink2, "end"),
text(130, 114, "weight in water", 12.5, C.ink2, "end"),
text(130, 132, "wait 3045 s", 12.5, C.ember, "end", "700"),
caption("gas pushes water away from coffee — bloom first, then brew"),
];
},
tamp() {
return [
el("rect", { x: 200, y: 100, width: 160, height: 60, rx: 8, fill: C.ink2 }),
el("rect", { x: 210, y: 110, width: 140, height: 42, rx: 4, fill: C.brown }),
el("rect", { x: 240, y: 40, width: 80, height: 40, rx: 6, fill: C.ink, class: "ac-tamp" }),
el("rect", { x: 270, y: 20, width: 20, height: 26, rx: 6, fill: C.ink }),
...[0, 1, 2].map((i) => el("path", { d: `M${232 + i * 40} 170 v 16`, stroke: C.ink2, "stroke-width": 2.5, class: `ac-pulse ac-delay-${i}` })),
text(280, 220, "dose to 0.1 g · distribute · tamp level", 13, C.ink, "middle", "700"),
text(280, 244, "pressurized water exploits any weakness — puck prep is channel prevention", 12),
// Tamper descending onto the basket
el("g", { class: "ac-tamp" },
el("rect", { x: 262, y: 44, width: 16, height: 26, rx: 5, fill: C.ink }),
el("rect", { x: 238, y: 70, width: 64, height: 20, rx: 5, fill: C.ink })),
el("rect", { x: 206, y: 118, width: 148, height: 54, rx: 8, fill: C.ink2 }),
el("rect", { x: 216, y: 128, width: 128, height: 36, rx: 4, fill: C.brown }),
el("line", { x1: 216, y1: 128, x2: 344, y2: 128, stroke: C.paper, "stroke-width": 1.5, "stroke-dasharray": "4 4" }),
text(390, 140, "level, even,", 12.5, C.ink2, "start"),
text(390, 158, "no gaps", 12.5, C.ink2, "start"),
text(170, 140, "dose to 0.1 g", 12.5, C.ink2, "end"),
text(280, 220, "distribute · tamp level · no ritual, just channel prevention", 13, C.ink, "middle", "700"),
caption("pressurized water exploits any weakness in the puck"),
];
},
shot() {
return [
el("rect", { x: 230, y: 60, width: 100, height: 34, rx: 6, fill: C.ink }),
el("path", { d: "M270 94 v 30 M290 94 v 30", stroke: C.brown, "stroke-width": 5, class: "ac-drip" }),
el("path", { d: "M252 140 h 56 l -6 34 h -44 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("rect", { x: 250, y: 152, width: 60, height: 0, fill: C.tan, class: "ac-grow-h-down" }),
text(400, 110, "18 g in → 36 g out", 13.5, C.ink, "start", "700"),
text(400, 134, "2532 seconds", 13, C.ink2, "start"),
text(400, 158, "flows like warm honey", 12.5, C.tan, "start"),
text(280, 240, "blonde & gushing → finer · choked drips → coarser", 12.5),
el("rect", { x: 230, y: 56, width: 100, height: 32, rx: 6, fill: C.ink }),
el("line", { x1: 268, y1: 88, x2: 268, y2: 122, stroke: C.brown, "stroke-width": 5, class: "ac-drip" }),
el("line", { x1: 292, y1: 88, x2: 292, y2: 122, stroke: C.brown, "stroke-width": 5, class: "ac-drip ac-delay-1" }),
el("path", { d: "M248 130 h 64 l -7 40 h -50 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("rect", { x: 252, y: 146, width: 56, height: 0, fill: C.tan, class: "ac-grow-h-down" }),
text(392, 108, "18 g in → 36 g out", 13, C.ink, "start", "700"),
text(392, 130, "in 2532 seconds", 12.5, C.ink2, "start"),
text(392, 152, "flows like warm honey", 12.5, C.tan, "start"),
text(168, 130, "9 bar, above", 12.5, C.ink2, "end"),
text(280, 220, "blonde & gushing → grind finer · choked drips → coarser", 12.5, C.ink),
caption("the stream itself tells you which way the grind is wrong"),
];
},
brewAnim({ method, mode }) {
const m = findBrewMethod(method);
const out = [
silhouette(method, 190, 40, 2.8, C.ink2),
text(280, 280, m?.name ?? method, 13.5, C.ink, "middle", "700"),
];
if (mode === "steep" || mode === "valve")
const [bx, by] = sil(...(BOWL[method] ?? [32, 30]));
const out = [silhouette(method), text(280, 262, m?.name ?? method, 13.5, C.ink, "middle", "700")];
const hint = (lines, color = C.ink2) =>
lines.forEach((s, i) => out.push(text(432, 100 + i * 19, s, 12.5, color, "start", i === 0 ? "700" : "400")));
if (mode === "steep" || mode === "valve") {
out.push(
...[...Array(6)].map((_, i) =>
el("circle", { cx: 250 + (i % 3) * 26, cy: 120 + Math.floor(i / 3) * 22, r: 4.5, fill: C.brown, class: `ac-float ac-delay-${i % 3}` }),
el("circle", {
cx: bx - 22 + (i % 3) * 22, cy: by - 8 + Math.floor(i / 3) * 16, r: 4,
fill: C.brown, class: `ac-float ac-delay-${i % 3}`,
}),
),
text(430, 120, mode === "valve" ? "steep… then open the valve" : "all the water, all the grounds", 12.5, C.ink2, "start"),
);
if (mode === "plunge" || mode === "press")
hint(mode === "valve" ? ["steep closed…", "then open the", "valve and drain"] : ["all the water,", "all the grounds,", "the whole time"]);
}
if (mode === "plunge" || mode === "press") {
out.push(
el("path", { d: "M275 70 v 50", stroke: C.ember, "stroke-width": 5, class: "ac-tamp" }),
text(430, 120, mode === "press" ? "steep 1:30, press 0:30" : "press gently — leave the fines behind", 12.5, C.ink2, "start"),
el("g", { class: "ac-tamp" },
el("path", { d: `M${bx} ${by - 52} v 30`, stroke: C.ember, "stroke-width": 5, "stroke-linecap": "round" }),
el("path", { d: `M${bx - 9} ${by - 20} h 18`, stroke: C.ember, "stroke-width": 4, "stroke-linecap": "round" })),
);
if (mode === "pour" || mode === "spiral" || mode === "drip")
hint(mode === "press" ? ["steep 1:30,", "press 0:30,", "gently"] : ["press slowly,", "stop above the", "grounds"]);
}
if (mode === "pour" || mode === "spiral" || mode === "drip") {
out.push(
el("path", { d: mode === "spiral" ? "M262 58 q 18 -14 36 0 q -18 12 -36 0" : "M268 46 q 12 -10 24 0", fill: "none", stroke: C.water, "stroke-width": 3, class: "ac-pourline" }),
...[0, 1, 2].map((i) => el("line", { x1: 272 + i * 8, y1: 60, x2: 272 + i * 8, y2: 78, stroke: C.water, "stroke-width": 2.5, class: `ac-drip ac-delay-${i}` })),
text(430, 110, mode === "spiral" ? "slow spirals, centre out" : mode === "drip" ? "the machine does the pouring" : "fresh water, always passing through", 12.5, C.ink2, "start"),
...[0, 1, 2].map((i) =>
el("line", { x1: bx - 10 + i * 10, y1: by - 34, x2: bx - 10 + i * 10, y2: by - 14, stroke: C.water, "stroke-width": 2.5, class: `ac-drip ac-delay-${i}` }),
),
);
if (mode === "pressure")
if (mode === "spiral")
out.push(el("path", { d: `M${bx} ${by} m -16 0 a 16 16 0 1 1 32 0 a 12 12 0 1 1 -24 0`, fill: "none", stroke: C.water, "stroke-width": 2, class: "ac-pourline" }));
hint(mode === "spiral" ? ["slow spirals,", "centre out,", "keep it level"] : mode === "drip" ? ["the robot pours;", "you choose grind", "and ratio"] : ["fresh water,", "always passing", "through the bed"]);
}
if (mode === "pressure") {
out.push(
el("path", { d: "M150 90 h 30", stroke: C.ember, "stroke-width": 5, class: "ac-pulse" }),
text(140, 70, "9 bar", 13.5, C.ember, "middle", "700"),
...[0, 1, 2].map((i) =>
el("path", { d: `M${bx - 26 + i * 26} ${by - 26} v 14`, stroke: C.ember, "stroke-width": 3.5, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }),
),
);
if (mode === "flame") out.push(flame(280, 292, 1.1), text(430, 150, "≈1.5 bar of boiler steam", 12.5, C.ink2, "start"));
hint(["9 bar pushes", "water through", "the puck"], C.ember);
}
if (mode === "flame") {
out.push(flame(bx, sil(32, 66)[1], 1));
hint(["≈1.5 bar of", "boiler steam", "pushes upward"]);
}
return out;
},
};
// ── Progress ───────────────────────────────────────────────────────────────
const PROGRESS_KEY = "academyProgress.v1";
function loadProgress() {
try {
return JSON.parse(localStorage.getItem(PROGRESS_KEY)) ?? {};
} catch {
return {};
}
}
function saveProgress(lessonId, patch) {
const all = loadProgress();
all[lessonId] = { ...all[lessonId], ...patch };
try {
localStorage.setItem(PROGRESS_KEY, JSON.stringify(all));
} catch {
/* storage unavailable */
}
}
// ── Player ─────────────────────────────────────────────────────────────────
let current = null; // {track, lesson, index}
@@ -492,6 +534,7 @@ let autoplay = true;
const $ = (id) => document.getElementById(id);
function renderMenu() {
const progress = loadProgress();
const mount = $("academy-menu");
mount.replaceChildren(
...ACADEMY_TRACKS.map((track) => {
@@ -512,20 +555,38 @@ function renderMenu() {
const list = document.createElement("div");
list.className = "academy-lessons";
track.lessons.forEach((lesson, li) => {
const state = progress[lesson.id] ?? {};
const resumeAt = state.done ? 0 : (state.slide ?? 0);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "academy-lesson";
const num = document.createElement("span");
num.className = "academy-lesson-num";
num.textContent = String(li + 1);
num.textContent = state.done ? "✓" : String(li + 1);
if (state.done) num.classList.add("done");
const mid = document.createElement("span");
mid.className = "academy-lesson-mid";
const label = document.createElement("span");
label.className = "academy-lesson-label";
label.textContent = lesson.title;
const meta = document.createElement("span");
meta.className = "academy-lesson-meta";
meta.textContent = `${lesson.slides.length} slides · ~${lesson.minutes} min`;
btn.append(num, label, meta);
btn.addEventListener("click", () => openLesson(track, lesson, 0));
meta.textContent = state.done
? `completed · ${lesson.slides.length} slides · ~${lesson.minutes} min`
: resumeAt > 0
? `resume at slide ${resumeAt + 1} of ${lesson.slides.length}`
: `${lesson.slides.length} slides · ~${lesson.minutes} min`;
const bar = document.createElement("span");
bar.className = "academy-progress";
const fill = document.createElement("span");
fill.className = "academy-progress-fill";
fill.style.width = state.done
? "100%"
: `${Math.round((resumeAt / lesson.slides.length) * 100)}%`;
bar.append(fill);
mid.append(label, meta, bar);
btn.append(num, mid);
btn.addEventListener("click", () => openLesson(track, lesson, resumeAt));
list.append(btn);
});
body.append(list);
@@ -547,20 +608,22 @@ function closeLesson() {
current = null;
$("academy-player").classList.add("hidden");
$("academy-menu").classList.remove("hidden");
renderMenu(); // reflect fresh progress
}
function renderSlide() {
const { lesson, index, track } = current;
const slide = lesson.slides[index];
saveProgress(lesson.id, { slide: index });
$("player-lesson").textContent = `${track.name}${lesson.title}`;
$("player-slide-title").textContent = slide.title;
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
// Scene: rebuild so its CSS animations restart with the slide.
// Rebuilt per slide so CSS animations restart with it.
const svg = $("player-scene");
svg.replaceChildren();
try {
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}, svg));
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
} catch {
svg.append(text(280, 150, slide.title, 16, C.ink2));
}
@@ -598,6 +661,7 @@ function setPlayState(playing) {
function next() {
const { lesson, index } = current;
if (index >= lesson.slides.length - 1) {
saveProgress(lesson.id, { done: true, slide: 0 });
closeLesson();
return;
}