import { protectedFetch } from "./api.js?v=__ASSET_VERSION__"; import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; import { ACADEMY_TRACKS } from "./academy-content.js?v=__ASSET_VERSION__"; import { BREW_SILHOUETTES, findBrewMethod } from "/shared/brew-data.js?v=__ASSET_VERSION__"; 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. const C = { green: "#7a8c5e", yellow: "#d9ba5a", tan: "#b98d54", brown: "#8a5a30", dark: "#54371f", ember: "#a8481a", emberSoft: "#fbefe6", ink: "#1b1614", ink2: "#5b524b", line: "#e4dcd2", water: "#5f8cb0", paper: "#fbf8f4", pass: "#2f6f4e", fail: "#b03a22", }; 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); } 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); /** A coffee bean (side profile with centre cut) at x,y. */ function bean(x, y, scale = 1, fill = C.green, opts = {}) { 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" })); return g; } function steamPaths(x, y, n = 3, cls = "ac-steam") { 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`, fill: "none", stroke: C.ink2, "stroke-width": 2.5, "stroke-linecap": "round", class: `${cls} ac-delay-${i}`, opacity: 0.55, }), ); return g; } 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 }), ); } function silhouette(methodKey, x, y, scale = 1, fill = C.ink) { const g = el("g", { transform: `translate(${x} ${y}) scale(${scale})`, fill }); for (const d of BREW_SILHOUETTES[methodKey] ?? []) g.append(el("path", { d })); return g; } 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. const SCENES = { beanAnatomy() { 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("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), ]; }, composition(_, slideEl) { const parts = [ ["Cellulose", 48, C.ink2], ["Sugars", 12, C.tan], ["Water", 11, C.water], ["Lipids", 12, C.yellow], ["Acids", 8, C.ember], ["Proteins", 9, C.brown], ]; let y = 60; const out = [bean(120, 150, 2.2, C.green)]; for (const [name, pct, color] of parts) { 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"), ); 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)); return out; }, charge() { return [ el("rect", { x: 70, y: 70, width: 200, height: 140, rx: 70, 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), ]; }, 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), ]; }, phaseBar({ highlight }) { const phases = [ ["drying", 47, C.yellow, "43–51%"], ["maillard", 36, C.tan, "33–39%"], ["development", 17, C.brown, "12–20%"], ]; 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" }), ...[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}` }), ), 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), ]; }, 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 }) { 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), ]; }, 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 = 12–20%", 13.5, C.ember, "middle", "700"), text(280, 235, "the strongest single lever on the cup", 12.5), ]; }, 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, "−11–13% 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), ]; }, 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]; 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"), ]; }, 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"; 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), ]; }, oneChange() { 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"), ), ), 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), ]; }, 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}` }), ), ...order.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"), ), ), text(280, 262, "hot water dissolves them in this order — brewing is choosing when to stop", 12.5), ]; }, 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, "18–22%", 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), ]; }, grind() { 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), ]; }, 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, "90–96 °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:15–1:17", 12.5, C.ink2)), ]; }, 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), ]; }, bloom() { return [ silhouette("v60", 210, 60, 2.2, C.ink2), ...[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}` }), ), 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 30–45 s", 13, C.ember, "middle", "700"), ]; }, 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), ]; }, 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, "25–32 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), ]; }, 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") 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}` }), ), 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") 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"), ); 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"), ); 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"), ); if (mode === "flame") out.push(flame(280, 292, 1.1), text(430, 150, "≈1.5 bar of boiler steam", 12.5, C.ink2, "start")); return out; }, }; // ── Player ───────────────────────────────────────────────────────────────── let current = null; // {track, lesson, index} const audio = new Audio(); let autoplay = true; const $ = (id) => document.getElementById(id); function renderMenu() { const mount = $("academy-menu"); mount.replaceChildren( ...ACADEMY_TRACKS.map((track) => { const wrap = document.createElement("section"); wrap.className = "panel-card"; const head = document.createElement("div"); head.className = "panel-head"; const h2 = document.createElement("h2"); h2.textContent = track.name; head.append(h2); const body = document.createElement("div"); body.className = "panel-body"; const blurb = document.createElement("p"); blurb.className = "muted"; blurb.style.marginTop = "0"; blurb.textContent = track.blurb; body.append(blurb); const list = document.createElement("div"); list.className = "academy-lessons"; track.lessons.forEach((lesson, li) => { 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); 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)); list.append(btn); }); body.append(list); wrap.append(head, body); return wrap; }), ); } function openLesson(track, lesson, index) { current = { track, lesson, index }; $("academy-menu").classList.add("hidden"); $("academy-player").classList.remove("hidden"); renderSlide(); } function closeLesson() { audio.pause(); current = null; $("academy-player").classList.add("hidden"); $("academy-menu").classList.remove("hidden"); } function renderSlide() { const { lesson, index, track } = current; const slide = lesson.slides[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. const svg = $("player-scene"); svg.replaceChildren(); try { svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}, svg)); } catch { svg.append(text(280, 150, slide.title, 16, C.ink2)); } $("player-caption").textContent = slide.transcript; const dots = $("player-dots"); dots.replaceChildren( ...lesson.slides.map((s, i) => { const dot = document.createElement("button"); dot.type = "button"; dot.className = `academy-dot${i === index ? " active" : ""}`; dot.setAttribute("aria-label", `Slide ${i + 1}: ${s.title}`); dot.addEventListener("click", () => { current.index = i; renderSlide(); }); return dot; }), ); $("player-prev").disabled = index === 0; $("player-next").textContent = index === lesson.slides.length - 1 ? "Finish lesson" : "Next →"; audio.src = `/academy-audio/${slide.id}.mp3?v=__ASSET_VERSION__`; if (autoplay) audio.play().catch(() => setPlayState(false)); setPlayState(autoplay); } function setPlayState(playing) { $("player-play").textContent = playing ? "❚❚" : "▶"; $("player-play").setAttribute("aria-label", playing ? "Pause narration" : "Play narration"); } function next() { const { lesson, index } = current; if (index >= lesson.slides.length - 1) { closeLesson(); return; } current.index++; renderSlide(); } function wirePlayer() { $("player-close").addEventListener("click", closeLesson); $("player-prev").addEventListener("click", () => { if (current.index > 0) { current.index--; renderSlide(); } }); $("player-next").addEventListener("click", next); $("player-play").addEventListener("click", () => { if (audio.paused) { autoplay = true; audio.play().catch(() => {}); setPlayState(true); } else { audio.pause(); setPlayState(false); } }); audio.addEventListener("ended", () => { setPlayState(false); // Give the animation a beat, then advance — hands-free lesson flow. if (autoplay && current) setTimeout(() => current && next(), 1200); }); document.addEventListener("keydown", (event) => { if (!current) return; if (event.key === "ArrowRight") next(); if (event.key === "ArrowLeft" && current.index > 0) { current.index--; renderSlide(); } if (event.key === "Escape") closeLesson(); if (event.key === " " && event.target === document.body) { event.preventDefault(); $("player-play").click(); } }); } document.getElementById("btn-logout").addEventListener("click", async () => { await protectedFetch("/api/auth/logout", { method: "POST" }); location.assign("/"); }); async function init() { initSideNav(); const user = await loadNavUser(); if (!user) return; renderMenu(); wirePlayer(); } init();