Files
roast_command_center/public/js/academy.js
T
Shane MaynardandClaude Fable 5 64c1b3605b
Test and deploy / test-and-deploy (push) Successful in 53s
Add the Academy: narrated, animated roasting & brewing lessons; cupping link on roast detail
Academy (/academy, 'Learn' nav group):
- 10 lessons / 45 slides across two tracks. Roasting: seed anatomy and
  composition, drying & the turning point, Maillard, first crack &
  development (DTR), curve/RoR reading (crash, flick, stall), defects →
  one-change discipline. Brewing: extraction physics (dissolving order,
  yield, grind, temp/time/ratio), then technique per brewer family —
  immersion (French press, AeroPress, Clever/Hario Switch, cold brew,
  cupping), percolation (bloom, V60, Chemex/Kalita, batch, percolator),
  espresso & pressure (puck prep, shot reading, dialing in, moka)
- One cohesive scene system: 25 parameterized animated SVG scenes drawn
  from the app's palette (beans, curves, phase bars, brewers reusing the
  silhouette library), CSS keyframe animations, reduced-motion support
- Narration: Azure TTS (en-US-Andrew HD, eastus) pre-generated to 45
  committed MP3s by scripts/generate-academy-audio.mjs; slides speak
  phonetic respellings (my-YARD, KEM-ex, MOH-kah…) while captions show
  normal spelling; hands-free autoplay advances after each clip
- Player: slide dots, keyboard arrows/space, per-slide captions

Roast detail: 'Open cupping session' button next to cup notes (opens or
creates the linked plan's session) — its score and flavors flow into
the updated .alog download.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-09 09:04:39 -04:00

661 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, "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" }),
...[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 = 1220%", 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, "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),
];
},
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, "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),
];
},
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, "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)),
];
},
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 3045 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, "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),
];
},
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();