Files
roast_command_center/public/js/academy-scenes.js
T
Shane MaynardandClaude Fable 5 2e1b8d24f9
Test and deploy / test-and-deploy (push) Successful in 56s
Academy v3: screenshot-verified scenes, transform-override fix, hero bean-journey slide
Root cause of the broken diagrams found and fixed: CSS transform
animations were overriding the SVG positioning transform on the same
element, throwing animated elements (beans, flames) to the canvas
origin — the bean/flame helpers now split position (outer group) from
animation (inner group).

Every scene was then rendered in headless Chromium and screenshot-
inspected, with fixes applied and re-verified: drying-scene label
collision, RoR ideal-line label overlap, first-crack dot off the curve,
composition bar stagger orphaning its labels, moka flame hidden behind
the base.

New opening slide (user request): 'The whole journey, in one bean' — a
large bean on a 14-second loop living the full roast (heat in, drying
steam out, green→yellow→brown, pressure swell, crack) synced to a
stage timeline with a riding marker; narrated like the rest.

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

552 lines
27 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 { 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, "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: 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, "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, 74)[1], 1.2));
hint(["≈1.5 bar of", "boiler steam", "pushes upward"]);
}
return out;
},
};