diff --git a/public/academy-audio/rj-journey.mp3 b/public/academy-audio/rj-journey.mp3 new file mode 100644 index 0000000..1252330 Binary files /dev/null and b/public/academy-audio/rj-journey.mp3 differ diff --git a/public/app.css b/public/app.css index 3be283d..ca9e797 100644 --- a/public/app.css +++ b/public/app.css @@ -3270,3 +3270,33 @@ select.f { .ac-needle { animation: ac-needle-kf 7s ease-in-out infinite; } @keyframes ac-pulse-soft-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.75; } } .ac-pulse-soft { animation: ac-pulse-soft-kf 2.4s ease-in-out infinite; } + +/* The 14-second "bean journey" clock — every piece of the hero scene rides it together. */ +@keyframes ac-jb-color-kf { + 0%, 30% { fill: #7a8c5e; } + 47% { fill: #d9ba5a; } + 66% { fill: #b98d54; } + 84%, 100% { fill: #8a5a30; } +} +.ac-jb-color { animation: ac-jb-color-kf 14s linear infinite; } +@keyframes ac-jb-grow-kf { + 0%, 40% { transform: scale(1); } + 84%, 100% { transform: scale(1.12); } +} +.ac-jb-grow { transform-box: fill-box; transform-origin: center; animation: ac-jb-grow-kf 14s linear infinite; } +@keyframes ac-jb-crack-kf { + 0%, 82% { opacity: 0; } + 86%, 100% { opacity: 1; } +} +.ac-jb-crack { animation: ac-jb-crack-kf 14s linear infinite; } +@keyframes ac-jb-steam-kf { + 0%, 8% { opacity: 0; } + 18%, 42% { opacity: 1; } + 52%, 100% { opacity: 0; } +} +.ac-jb-steam { animation: ac-jb-steam-kf 14s linear infinite; } +@keyframes ac-jb-marker-kf { + from { transform: translateX(0); } + to { transform: translateX(400px); } +} +.ac-jb-marker { animation: ac-jb-marker-kf 14s linear infinite; } diff --git a/public/js/academy-content.js b/public/js/academy-content.js index 826d937..2965ab1 100644 --- a/public/js/academy-content.js +++ b/public/js/academy-content.js @@ -20,6 +20,13 @@ export const ACADEMY_TRACKS = [ title: "The roast, start to finish", minutes: 9, slides: [ + { + id: "rj-journey", + title: "The whole journey, in one bean", + scene: ["beanJourney", {}], + transcript: + "Watch one bean live the whole roast. Heat pours in from below; first the bean sweats its water out, then it turns yellow and browns as aromas build, swelling under internal pressure until it cracks open — and we drop it. Everything in this lesson is just slowing this loop down.", + }, { id: "gb-seed", title: "A seed, not a bean", diff --git a/public/js/academy-scenes.js b/public/js/academy-scenes.js new file mode 100644 index 0000000..1cd4b8a --- /dev/null +++ b/public/js/academy-scenes.js @@ -0,0 +1,551 @@ +import { BREW_SILHOUETTES, findBrewMethod } from "/shared/brew-data.js?v=__ASSET_VERSION__"; + +const NS = "http://www.w3.org/2000/svg"; + +// ── 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", + 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)) node.setAttribute(k, v); + node.append(...children); + return node; +} +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); + +export const sceneFallbackText = (title) => text(280, 150, title, 16, C.ink2); + +/** Coffee bean, side profile with centre cut, centred on (x,y). Height ≈ 72·scale. + * CRITICAL: the positional transform lives on the OUTER group and any animated class on an + * INNER group — a CSS transform animation on the same element would silently override the + * SVG transform attribute and throw the bean to the canvas origin. */ +function bean(x, y, scale = 1, fill = C.green, opts = {}) { + const inner = el("g", { class: opts.class ?? "" }); + inner.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) + inner.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 el("g", { transform: `translate(${x} ${y}) scale(${scale})` }, inner); +} + +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 - (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.4, + "stroke-linecap": "round", + class: `ac-steam ac-delay-${i}`, + opacity: 0.5, + }), + ); + return g; +} + +function flame(x, y, scale = 1) { + // Same outer/inner split as bean(): position on the outer, animation class on the inner. + return el("g", { transform: `translate(${x} ${y}) scale(${scale})` }, + el("g", { class: "ac-flame" }, + 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 }), + ), + ); +} + +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], +}; + +// ── Scene builders (each returns children for the 560×300 canvas) ───────── + +export const SCENES = { + /** The hero: one large bean living the whole roast — heat in, water out, color, swell, + * crack — synced to a stage timeline underneath. All keyframes share one 14s loop. */ + beanJourney() { + const cx = 190, cy = 120; + const seg = (x, w, color, label) => [ + el("rect", { x, y: 236, width: w, height: 12, rx: 3, fill: color, opacity: 0.55 }), + text(x + w / 2, 264, label, 11.5, C.ink2), + ]; + return [ + // Heat under the bean, always on + flame(cx - 34, 226, 0.85), flame(cx, 232, 1.05), flame(cx + 34, 226, 0.85), + ...[0, 1, 2].map((i) => + el("path", { d: `M${cx - 26 + i * 26} 214 v -12`, stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` }), + ), + text(cx, 228, "", 1), + // The bean itself: color, swell, crack all on the 14s journey clock + el("g", { transform: `translate(${cx} ${cy}) scale(2.15)` }, + el("g", { class: "ac-jb-grow" }, + el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: C.green, class: "ac-jb-color" }), + 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)" }), + 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-jb-crack" }), + ), + ), + // Moisture leaving — fades out as drying ends + el("g", { class: "ac-jb-steam" }, steamWisps(cx, 46, 3)), + // Physics callouts + text(322, 60, "water out early —", 12.5, C.water, "start", "700"), + text(322, 78, "the drying sweat", 12, C.ink2, "start"), + text(322, 116, "then browning:", 12.5, C.tan, "start", "700"), + text(322, 134, "Maillard aromas build", 12, C.ink2, "start"), + text(322, 172, "pressure swells the bean", 12.5, C.ink2, "start"), + text(322, 190, "until it cracks — then drop", 12.5, C.ember, "start", "700"), + // Stage timeline with a marker riding the same clock + ...seg(80, 188, C.yellow, "drying"), + ...seg(270, 144, C.tan, "Maillard"), + ...seg(416, 64, C.brown, "development"), + el("circle", { cx: 80, cy: 242, r: 7, fill: C.ember, class: "ac-jb-marker" }), + caption("one bean, one loop: green → yellow → brown → crack → drop"), + ]; + }, + beanAnatomy() { + const cx = 180, cy = 145; + return [ + 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}` }), + ), + // 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() { + const parts = [ + ["Cellulose", 48, C.ink2], + ["Sugars", 12, C.tan], + ["Lipids", 12, C.yellow], + ["Water", 11, C.water], + ["Proteins", 9, C.brown], + ["Acids", 8, C.ember], + ]; + 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( + 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", style: `--w:${w}px` }), + text(266 + w, y + 16, `${pct}%`, 12, C.ink2, "start"), + ); + }); + out.push(caption("half scaffolding, half flavor pantry — plus the water")); + return out; + }, + charge() { + return [ + // 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(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(170, 272, 1.1), + bean(170, 150, 1.9, C.green, { class: "ac-dry-shift" }), + steamWisps(170, 92, 3), + text(226, 56, "water out", 12, C.water, "start", "700"), + text(206, 262, "heat in", 12, C.ember, "start", "700"), + // 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, "43–51% of the roast", 12, C.ink2, "start"), + caption("evaporation soaks up energy while the bean fades green → yellow"), + ]; + }, + maillard() { + return [ + 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${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", + }), + ), + // 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"), + ]; + }, + crack({ phase = "during" } = {}) { + const during = phase === "during"; + return [ + 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 [ + 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 = 12–20%", 14, C.ember, "middle", "700"), + caption("the strongest single lever on the cup — the app computes it from every log"), + ]; + }, + weightLoss() { + return [ + 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, "−11–13% for a light roast", 13.5, C.ember, "middle", "700"), + caption("time, temperature and development — measured by a kitchen scale"), + ]; + }, + 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 [ + 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: 86, r: 5, fill: C.ink, class: "ac-pulse" }), + text(375, 56, "first crack", 12, C.ink, "middle", "700"), + text(165, 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"), + ]; + }, + ror() { + return [ + 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(216, 90, "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 [ + ...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: "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() { + return [ + 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}`, + }), + ), + ...[ + ["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: 312, cy: 96 + i * 44, r: 8, fill: color }), + text(330, 101 + i * 44, label, 13, C.ink2, "start"), + ), + ), + caption("hot water dissolves them in this order — brewing is choosing when to stop"), + ]; + }, + yieldMap() { + return [ + text(150, 92, "sour · thin", 12.5, C.fail, "middle", "700"), + text(280, 78, "sweet spot", 13, C.pass, "middle", "700"), + text(280, 96, "18–22%", 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 [ + 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 [ + // 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, "90–96 °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:15–1: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: "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"), + // CO₂ bubbles rising out of the cone + ...[0, 1, 2, 3].map((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, 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 30–45 s", 12.5, C.ember, "end", "700"), + caption("gas pushes water away from coffee — bloom first, then brew"), + ]; + }, + tamp() { + return [ + // 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: 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 25–32 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 [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: 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}`, + }), + ), + ); + 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("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" })), + ); + 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( + ...[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 === "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( + ...[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}` }), + ), + ); + hint(["9 bar pushes", "water through", "the puck"], C.ember); + } + if (mode === "flame") { + out.push(flame(bx, sil(32, 74)[1], 1.2)); + hint(["≈1.5 bar of", "boiler steam", "pushes upward"]); + } + return out; + }, +}; + + diff --git a/public/js/academy.js b/public/js/academy.js index 0c5438d..2913f56 100644 --- a/public/js/academy.js +++ b/public/js/academy.js @@ -1,509 +1,7 @@ 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"; - -// ── 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", - 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)) node.setAttribute(k, v); - node.append(...children); - return node; -} -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); - -/** 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 ?? "", - }); - 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 steamWisps(x, y, n = 3) { - const g = el("g"); - for (let i = 0; i < n; i++) - g.append( - el("path", { - 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.4, - "stroke-linecap": "round", - class: `ac-steam ac-delay-${i}`, - opacity: 0.5, - }), - ); - 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 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 }), - ); -} - -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], -}; - -// ── Scene builders (each returns children for the 560×300 canvas) ───────── - -const SCENES = { - beanAnatomy() { - const cx = 180, cy = 145; - return [ - 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}` }), - ), - // 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() { - const parts = [ - ["Cellulose", 48, C.ink2], - ["Sugars", 12, C.tan], - ["Lipids", 12, C.yellow], - ["Water", 11, C.water], - ["Proteins", 9, C.brown], - ["Acids", 8, C.ember], - ]; - 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( - 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"), - ); - }); - out.push(caption("half scaffolding, half flavor pantry — plus the water")); - return out; - }, - charge() { - return [ - // 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(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(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, "43–51% of the roast", 12, C.ink2, "start"), - caption("evaporation soaks up energy while the bean fades green → yellow"), - ]; - }, - maillard() { - return [ - 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${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", - }), - ), - // 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"), - ]; - }, - crack({ phase = "during" } = {}) { - const during = phase === "during"; - return [ - 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 [ - 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 = 12–20%", 14, C.ember, "middle", "700"), - caption("the strongest single lever on the cup — the app computes it from every log"), - ]; - }, - weightLoss() { - return [ - 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, "−11–13% for a light roast", 13.5, C.ember, "middle", "700"), - caption("time, temperature and development — measured by a kitchen scale"), - ]; - }, - 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 [ - 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"), - ]; - }, - ror() { - return [ - 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 [ - ...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: "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() { - return [ - 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}`, - }), - ), - ...[ - ["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: 312, cy: 96 + i * 44, r: 8, fill: color }), - text(330, 101 + i * 44, label, 13, C.ink2, "start"), - ), - ), - caption("hot water dissolves them in this order — brewing is choosing when to stop"), - ]; - }, - yieldMap() { - return [ - text(150, 92, "sour · thin", 12.5, C.fail, "middle", "700"), - text(280, 78, "sweet spot", 13, C.pass, "middle", "700"), - text(280, 96, "18–22%", 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 [ - 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 [ - // 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, "90–96 °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:15–1: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: "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"), - // CO₂ bubbles rising out of the cone - ...[0, 1, 2, 3].map((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, 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 30–45 s", 12.5, C.ember, "end", "700"), - caption("gas pushes water away from coffee — bloom first, then brew"), - ]; - }, - tamp() { - return [ - // 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: 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 25–32 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 [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: 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}`, - }), - ), - ); - 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("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" })), - ); - 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( - ...[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 === "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( - ...[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}` }), - ), - ); - 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; - }, -}; +import { SCENES, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__"; // ── Progress ─────────────────────────────────────────────────────────────── @@ -625,7 +123,7 @@ function renderSlide() { try { svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {})); } catch { - svg.append(text(280, 150, slide.title, 16, C.ink2)); + svg.append(sceneFallbackText(slide.title)); } $("player-caption").textContent = slide.transcript;