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

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

725 lines
31 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";
// ── 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, "4351% 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 = 1220%", 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, "1113% 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, "1822%", 13, C.pass, "middle", "700"),
text(412, 92, "bitter · drying", 12.5, C.fail, "middle", "700"),
el("rect", { x: 90, y: 112, width: 380, height: 56, rx: 10, fill: C.paper, stroke: C.line }),
el("rect", { x: 90, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
el("rect", { x: 342, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
el("rect", { x: 218, y: 112, width: 124, height: 56, fill: C.pass, opacity: 0.16 }),
el("circle", { cx: 280, cy: 140, r: 7, fill: C.ember, class: "ac-slide-x" }),
text(280, 194, "extraction yield →", 12, C.ink2),
text(280, 230, "strength = coffee in the water", 12.5, C.ink2),
text(280, 248, "extraction = what you took from the grounds", 12.5, C.ink2),
caption("aim the dot at the middle: every dial below exists to move it"),
];
},
grind() {
const pile = (x, label, sub, circles) => {
const g = el("g", {});
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: C.brown }));
g.append(text(x, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
return g;
};
return [
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, "9096 °C", 12.5, C.ink, "middle", "700"),
// Time: a gauge whose needle sweeps
el("path", { d: "M220 190 A 62 62 0 0 1 340 190", fill: "none", stroke: C.line, "stroke-width": 10, "stroke-linecap": "round" }),
el("line", { x1: 280, y1: 190, x2: 280, y2: 138, stroke: C.ink, "stroke-width": 4, class: "ac-needle", "transform-origin": "280px 190px" }),
el("circle", { cx: 280, cy: 190, r: 8, fill: C.ink }),
text(280, 240, "time = how far down the list", 12.5, C.ink, "middle", "700"),
// Ratio: one bean to many drops
bean(420, 110, 0.62, C.brown),
text(446, 116, "1 :", 15, C.ink, "start", "700"),
...[0, 1, 2].map((i) =>
el("path", { d: `M${480 + i * 18} 104 c 5 8 5 14 0 18 c -5 -4 -5 -10 0 -18 Z`, fill: C.water, class: `ac-pulse ac-delay-${i}` }),
),
text(466, 240, "1:151:17", 12.5, C.ink, "middle", "700"),
caption("three dials on one cup — and grind, from the previous slide, is the fourth"),
];
},
tasteDial() {
return [
el("path", { d: "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 3045 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 2532 seconds", 12.5, C.ink2, "start"),
text(392, 152, "flows like warm honey", 12.5, C.tan, "start"),
text(168, 130, "9 bar, above", 12.5, C.ink2, "end"),
text(280, 220, "blonde & gushing → grind finer · choked drips → coarser", 12.5, C.ink),
caption("the stream itself tells you which way the grind is wrong"),
];
},
brewAnim({ method, mode }) {
const m = findBrewMethod(method);
const [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;
},
};
// ── Progress ───────────────────────────────────────────────────────────────
const PROGRESS_KEY = "academyProgress.v1";
function loadProgress() {
try {
return JSON.parse(localStorage.getItem(PROGRESS_KEY)) ?? {};
} catch {
return {};
}
}
function saveProgress(lessonId, patch) {
const all = loadProgress();
all[lessonId] = { ...all[lessonId], ...patch };
try {
localStorage.setItem(PROGRESS_KEY, JSON.stringify(all));
} catch {
/* storage unavailable */
}
}
// ── Player ─────────────────────────────────────────────────────────────────
let current = null; // {track, lesson, index}
const audio = new Audio();
let autoplay = true;
const $ = (id) => document.getElementById(id);
function renderMenu() {
const progress = loadProgress();
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 state = progress[lesson.id] ?? {};
const resumeAt = state.done ? 0 : (state.slide ?? 0);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "academy-lesson";
const num = document.createElement("span");
num.className = "academy-lesson-num";
num.textContent = state.done ? "✓" : String(li + 1);
if (state.done) num.classList.add("done");
const mid = document.createElement("span");
mid.className = "academy-lesson-mid";
const label = document.createElement("span");
label.className = "academy-lesson-label";
label.textContent = lesson.title;
const meta = document.createElement("span");
meta.className = "academy-lesson-meta";
meta.textContent = state.done
? `completed · ${lesson.slides.length} slides · ~${lesson.minutes} min`
: resumeAt > 0
? `resume at slide ${resumeAt + 1} of ${lesson.slides.length}`
: `${lesson.slides.length} slides · ~${lesson.minutes} min`;
const bar = document.createElement("span");
bar.className = "academy-progress";
const fill = document.createElement("span");
fill.className = "academy-progress-fill";
fill.style.width = state.done
? "100%"
: `${Math.round((resumeAt / lesson.slides.length) * 100)}%`;
bar.append(fill);
mid.append(label, meta, bar);
btn.append(num, mid);
btn.addEventListener("click", () => openLesson(track, lesson, resumeAt));
list.append(btn);
});
body.append(list);
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");
renderMenu(); // reflect fresh progress
}
function renderSlide() {
const { lesson, index, track } = current;
const slide = lesson.slides[index];
saveProgress(lesson.id, { slide: index });
$("player-lesson").textContent = `${track.name}${lesson.title}`;
$("player-slide-title").textContent = slide.title;
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
// Rebuilt per slide so CSS animations restart with it.
const svg = $("player-scene");
svg.replaceChildren();
try {
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
} 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) {
saveProgress(lesson.id, { done: true, slide: 0 });
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();