Academy v4: 3 research-backed lessons (30 slides + audio); new bean logo; custom plan/lot names
Test and deploy / test-and-deploy (push) Successful in 59s

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
Shane Maynard
2026-08-09 14:30:42 -04:00
co-authored by Claude Fable 5
parent ec078116b4
commit 8b80c3fa6f
65 changed files with 1315 additions and 78 deletions
+653
View File
@@ -87,6 +87,89 @@ function flame(x, y, scale = 1) {
);
}
let halfBeanClipSeq = 0;
/** Cross-section "cut bean" — outer shell path + a clipped inner area that can hold cell
* texture, moisture dots, gradient fills, etc. Same outer/inner split as bean(): the
* positional transform lives on the OUTER group; opts.class (if any) animates the INNER group. */
function halfBean(x, y, scale = 1, opts = {}) {
const shapeD =
"M0,-58 C 28,-58 44,-32 44,0 C 44,32 28,58 0,58 C -28,58 -44,32 -44,0 C -44,-32 -28,-58 0,-58 Z";
const id = `hb-clip-${halfBeanClipSeq++}`;
const shell = el("path", {
d: shapeD,
fill: opts.shellFill ?? C.tan,
stroke: opts.stroke ?? C.dark,
"stroke-width": opts.strokeWidth ?? 3,
});
const clip = el("clipPath", { id }, el("path", { d: shapeD }));
const content = el("g", { "clip-path": `url(#${id})` }, ...(opts.children ?? []));
const inner = el("g", { class: opts.class ?? "" }, shell, content);
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` }, clip, inner);
}
/** Small semi-circle dial gauge with a needle that sweeps toward the reading on a loop.
* Positional transform stays on the outer group; the needle's own transform is purely the
* rotation animation (no static transform competing on the same element), so no override risk. */
function gauge(x, y, label, valueLabel, opts = {}) {
const r = opts.r ?? 42;
const color = opts.color ?? C.ember;
const children = [
el("path", {
d: `M ${-r} 0 A ${r} ${r} 0 0 1 ${r} 0`,
fill: "none",
stroke: C.line,
"stroke-width": 8,
"stroke-linecap": "round",
}),
el("line", {
x1: 0, y1: 0, x2: 0, y2: -r + 10,
stroke: color, "stroke-width": 4, "stroke-linecap": "round",
class: opts.cls ?? "ac-gauge-needle",
"transform-origin": "0px 0px",
}),
el("circle", { cx: 0, cy: 0, r: 6, fill: color }),
valueLabel ? text(0, -16, valueLabel, 12.5, C.ink, "middle", "700") : null,
label ? text(0, r + 20, label, 11.5, C.ink2, "middle") : null,
].filter(Boolean);
return el("g", { transform: `translate(${x} ${y})` }, ...children);
}
/** Straight-segment curve through points, optionally drawn live via the ac-draw class. */
const arcCurve = (points, color, cls = "") =>
el("path", {
d: points.map(([px, py], i) => `${i === 0 ? "M" : "L"}${px} ${py}`).join(" "),
fill: "none",
stroke: color,
"stroke-width": 2.5,
class: cls,
});
/** Horizontal phase strip, reused from beanJourney's inline segments. `segments` is
* [{x,w,color,label}]; an optional marker dot can ride a CSS translate class across it. */
function stageStrip(segments, opts = {}) {
const y = opts.y ?? 236, h = opts.h ?? 12;
const out = [];
for (const s of segments) {
out.push(el("rect", { x: s.x, y, width: s.w, height: h, rx: 3, fill: s.color, opacity: 0.55 }));
if (s.label) out.push(text(s.x + s.w / 2, y + 28, s.label, 11, C.ink2));
}
if (opts.markerX != null)
out.push(el("circle", { cx: opts.markerX, cy: y + h / 2, r: 6, fill: opts.markerColor ?? C.ember, class: opts.markerCls ?? "" }));
return out;
}
/** Simple line-glyph for the nose / eye / ear senses, ~24px across, centred on (x,y). */
function senseIcon(kind, x, y, color = C.ink2) {
const g = el("g", { transform: `translate(${x} ${y})`, stroke: color, "stroke-width": 2, fill: "none", "stroke-linecap": "round" });
if (kind === "nose")
g.append(el("path", { d: "M-3 -12 C 8 -10, 10 4, 2 10 C -2 13, -8 12, -10 8", }), el("circle", { cx: 1, cy: 10, r: 1.6, fill: color }));
if (kind === "eye")
g.append(el("path", { d: "M-13 0 C -6 -9, 6 -9, 13 0 C 6 9, -6 9, -13 0 Z" }), el("circle", { cx: 0, cy: 0, r: 4, fill: color }));
if (kind === "ear")
g.append(el("path", { d: "M-4 -12 C 8 -12, 12 -2, 8 6 C 5 11, -2 11, -3 5 C -4 0, 3 1, 2 -4" }));
return g;
}
const SIL_SCALE = 2.6;
const SIL_X = 280 - 32 * SIL_SCALE; // silhouette 64-box centred horizontally
const SIL_Y = 34;
@@ -546,6 +629,576 @@ export const SCENES = {
}
return out;
},
// ── Inside the bean ──
ibCells() {
const bx = 120, by = 140;
const cx = 400, cy = 140, cr = 90;
const hexPositions = [[0, -40], [38, -20], [38, 20], [0, 40], [-38, 20], [-38, -20], [0, 0]];
const hexPath = (x, y, r) => {
const pts = [...Array(6)].map((_, i) => {
const a = Math.PI / 6 + (i * Math.PI) / 3;
return `${x + r * Math.cos(a)} ${y + r * Math.sin(a)}`;
});
return `M${pts.join(" L")} Z`;
};
const cells = hexPositions.map(([dx, dy], i) => {
const hx = cx + dx * 0.9, hy = cy + dy * 0.9;
return el("g", {},
el("path", { d: hexPath(hx, hy, 21), fill: "none", stroke: C.ink2, "stroke-width": 1.3 }),
el("circle", { cx: hx - 7, cy: hy - 4, r: 3, fill: C.water, class: `ac-sparkle ac-delay-${i % 3}` }),
el("circle", { cx: hx + 6, cy: hy - 4, r: 3, fill: C.yellow, class: `ac-sparkle ac-delay-${(i + 1) % 3}` }),
el("circle", { cx: hx, cy: hy + 7, r: 3, fill: C.tan, class: `ac-sparkle ac-delay-${(i + 2) % 3}` }),
);
});
return [
bean(bx, by, 1.85, C.green),
el("circle", { cx, cy, r: cr, fill: C.paper, stroke: C.line, "stroke-width": 2 }),
...cells,
el("line", { x1: bx + 50, y1: by - 28, x2: cx - cr + 8, y2: cy - cr + 18, stroke: C.line, "stroke-width": 1.5, "stroke-dasharray": "3 3" }),
el("line", { x1: bx + 50, y1: by + 28, x2: cx - cr + 8, y2: cy + cr - 18, stroke: C.line, "stroke-width": 1.5, "stroke-dasharray": "3 3" }),
text(cx, cy - cr - 10, "one cell, magnified", 12, C.ink2, "middle"),
caption("one bean ≈ a few million sealed cells — oil, sugar, and water inside each"),
];
},
ibSeal() {
const cx = 280, cy = 140, half = 64;
const pores = [
[cx, cy - half, 0, 28],
[cx, cy + half, 0, -28],
[cx - half, cy, 28, 0],
[cx + half, cy, -28, 0],
];
return [
el("rect", { x: cx - half, y: cy - half, width: half * 2, height: half * 2, rx: 16, fill: "rgba(0,0,0,0.04)", stroke: C.dark, "stroke-width": 4 }),
...pores.map(([px, py, dx, dy], i) =>
el("g", {},
el("rect", { x: px - 9, y: py - 9, width: 18, height: 18, fill: C.paper }),
el("circle", { cx: px - dx, cy: py - dy, r: 9, fill: C.yellow, class: `ac-plug ac-delay-${i}`, style: `--dx:${dx}px;--dy:${dy}px` }),
),
),
...[[-1, -1], [1, -1], [-1, 1], [1, 1]].map(([sx, sy], i) =>
el("path", {
d: `M${cx + sx * (half + 12)} ${cy + sy * (half + 12)} l ${sx * 10} ${sy * 10}`,
stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}`,
}),
),
text(cx, cy - half - 18, "cell wall", 12, C.ink2, "middle"),
text(cx, cy - 4, "pressure", 12.5, C.ember, "middle", "700"),
text(cx, cy + 14, "building", 12.5, C.ember, "middle", "700"),
caption("early heat drives oil into the pores — each cell seals into a pressure vessel"),
];
},
ibGlass() {
const lx = 140, rx = 420, cy = 150;
return [
bean(lx, cy, 1.6, C.tan),
el("path", { d: `M${lx - 14} ${cy - 24} l 8 14 l -8 10 l 8 12`, stroke: C.paper, "stroke-width": 1.8, fill: "none" }),
text(lx, cy + 92, "glassy", 13, C.ink, "middle", "700"),
text(lx, cy + 108, "hard, locked", 11.5, C.ink2, "middle"),
bean(rx, cy, 1.6, C.yellow, { class: "ac-wobble" }),
text(rx, cy + 92, "rubbery", 13, C.ink, "middle", "700"),
text(rx, cy + 108, "soft, stretchy", 11.5, C.ink2, "middle"),
el("path", { d: `M${lx + 46} ${cy - 50} Q 280 ${cy - 92} ${rx - 46} ${cy - 50}`, fill: "none", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }),
text(280, cy - 98, "heat", 12.5, C.ember, "middle", "700"),
el("path", { d: `M${rx - 46} ${cy + 50} Q 280 ${cy + 92} ${lx + 46} ${cy + 50}`, fill: "none", stroke: C.water, "stroke-width": 2.5, class: "ac-draw" }),
text(280, cy + 100, "cool + dry", 12.5, C.water, "middle", "700"),
caption("warm + wet = rubbery and inflatable · cool + dry = glassy and locked"),
];
},
ibFront() {
const x = 190, y = 160, scale = 1.55;
return [
halfBean(x, y, scale, {
shellFill: C.tan,
children: [el("circle", { cx: 0, cy: 0, r: 30, fill: C.water, class: "ac-shrink" })],
}),
steamWisps(x, 74, 3),
text(x, 50, "water leaves the surface", 12, C.water, "middle"),
el("line", { x1: x + 50, y1: y - 30, x2: 356, y2: 70, stroke: C.line, "stroke-width": 1.5 }),
text(364, 66, "dry shell — hot, reacting", 12, C.tan, "start", "700"),
el("line", { x1: x + 26, y1: y + 12, x2: 356, y2: 180, stroke: C.line, "stroke-width": 1.5 }),
text(364, 176, "wet core — pinned", 12, C.water, "start", "700"),
text(364, 194, "near boiling", 12, C.ink2, "start"),
caption("water leaves outside-in — a wet core retreats behind a hot, dry shell"),
];
},
ibPressure() {
const bars = [["car tire", 2, C.ink2], ["espresso", 9, C.water], ["first crack", 15, C.ember]];
const baseY = 232, maxH = 130, maxV = 15;
return [
gauge(140, 150, null, "≈15 bar", { r: 46 }),
text(140, 216, "pressure follows", 11, C.ink2, "middle"),
text(140, 230, "temperature", 11, C.ink2, "middle"),
...bars.map(([label, val, color], i) => {
const x = 300 + i * 76;
const h = (val / maxV) * maxH;
return el("g", {},
el("rect", { x: x - 20, y: baseY - h, width: 40, height: h, rx: 6, fill: color, opacity: i === 2 ? 1 : 0.55 }),
text(x, baseY + 18, label, 11, C.ink2, "middle"),
text(x, baseY - h - 8, `${val} bar`, 11.5, C.ink, "middle", "700"),
);
}),
caption("while water remains, physics pins the pressure to the temperature"),
];
},
ibCrack() {
const x = 280, y = 150, scale = 1.7;
return [
halfBean(x, y, scale, {
shellFill: C.brown, strokeWidth: 5, stroke: C.dark,
children: [
el("circle", { cx: 0, cy: 0, r: 26, fill: C.tan }),
...[0, 1, 2, 3].map((i) => {
const a = i * (Math.PI / 2) + Math.PI / 4;
const x1 = Math.cos(a) * 30, y1 = Math.sin(a) * 30, x2 = Math.cos(a) * 40, y2 = Math.sin(a) * 40;
return el("path", { d: `M${x1} ${y1} L${x2} ${y2}`, stroke: C.ember, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i}` });
}),
...[[-30, -46], [30, -46], [-36, 40], [36, 40]].map(([sx, sy]) =>
el("path", { d: `M${sx - 4} ${sy} l 8 0`, stroke: C.paper, "stroke-width": 1.6 }),
),
],
}),
el("g", { transform: `translate(${x} ${y}) scale(${scale})` },
el("path", { d: "M-2 -50 L 6 -20 L -6 6 L 6 30 L -2 52", fill: "none", stroke: C.paper, "stroke-width": 2.6, class: "ac-crackline" }),
),
text(x, y - scale * 58 - 14, "stiff, dried shell", 12, C.ink2, "middle"),
text(x, 250, "fewer than 1 in 10 beans pops audibly", 12, C.ember, "middle", "700"),
caption("a rubbery, pressurized core strains against a stiff shell — until it fails"),
];
},
ibFoam() {
const y = 150, leftX = 160, rightX = 400;
const pores = (n, seed) => [...Array(n)].map((_, i) => {
const ang = (i / n) * Math.PI * 2 + seed;
const rad = 14 + (i % 3) * 8;
return el("circle", { cx: Math.cos(ang) * rad, cy: Math.sin(ang) * rad, r: 3 + (i % 2), fill: C.paper, opacity: 0.85 });
});
return [
halfBean(leftX, y, 1.3, { shellFill: C.green, children: pores(5, 0) }),
text(leftX, y + 80, "green", 12.5, C.ink, "middle", "700"),
text(leftX, y + 98, "14% air · density 1.1", 11, C.ink2, "middle"),
halfBean(rightX, y, 1.3, { shellFill: C.tan, class: "ac-grow-foam", children: pores(11, 1) }),
text(rightX, y + 80, "roasted", 12.5, C.ink, "middle", "700"),
text(rightX, y + 98, "~46% air · density 0.65", 11, C.ink2, "middle"),
el("path", { d: `M${leftX + 70} ${y} L ${rightX - 70} ${y}`, stroke: C.ember, "stroke-width": 2, "stroke-dasharray": "4 4" }),
text(280, y - 6, "≈1.5×", 12.5, C.ember, "middle", "700"),
text(280, 244, "expansion is steady — not a sudden pop", 12, C.ink2, "middle"),
caption("half the roasted bean is air — and it swelled steadily, not in one bang"),
];
},
ibMortar() {
const gx = 150, gy = 56, cw = 100, ch = 58;
const bricks = [];
for (let r = 0; r < 2; r++)
for (let c = 0; c < 3; c++)
bricks.push(el("rect", { x: gx + c * cw, y: gy + r * ch, width: cw - 10, height: ch - 10, rx: 6, fill: C.tan, class: `ac-mortar-fade ac-delay-${(r + c) % 3}` }));
const rebarXs = [gx + 8, gx + 8 + cw, gx + 8 + cw * 2];
const rebar = rebarXs.map((rx, i) =>
el("g", {},
el("rect", { x: rx, y: gy - 16, width: 9, height: ch * 2 + 30, rx: 4, fill: C.dark }),
i === 1 ? el("path", { d: `M${rx + 4} ${gy + ch * 2 - 2} l 6 10 l -6 8 l 6 10`, stroke: C.paper, "stroke-width": 2, fill: "none", class: "ac-rebar-snap" }) : null,
),
);
return [
text(280, 34, "cell wall, close up", 12, C.ink2, "middle"),
...bricks, ...rebar,
text(280, 210, "mortar: soft sugars & gums", 12, C.tan, "middle", "700"),
text(280, 226, "— up to 60% dissolves away", 11, C.ink2, "middle"),
text(280, 244, "rebar: cellulose barely reacts", 12, C.dark, "middle", "700"),
text(280, 260, "chars & snaps only very dark — 2nd crack", 10.5, C.ink2, "middle"),
caption("the wall's soft mortar dissolves so the bean stretches — cellulose rebar holds"),
];
},
ibCo2() {
const beanX = 130, groundX = 380, y = 130;
return [
bean(beanX, y, 1.7, C.brown, { crack: true }),
...[0, 1, 2].map((i) => el("circle", { cx: beanX - 14 + i * 14, cy: y - 40 - i * 6, r: 3, fill: C.tan, class: `ac-rise ac-delay-${i}` })),
text(beanX, y + 70, "whole bean", 12.5, C.ink, "middle", "700"),
text(beanX, y + 88, "~a month to degas", 10.5, C.ink2, "middle"),
...[...Array(9)].map((_, i) => el("circle", { cx: groundX - 30 + (i % 3) * 30, cy: y + 10 + Math.floor(i / 3) * 18, r: 8, fill: C.brown })),
...[0, 1, 2, 3, 4].map((i) => el("circle", { cx: groundX - 24 + i * 12, cy: y - 30 - i * 4, r: 3.5, fill: C.tan, class: `ac-burst ac-delay-${i % 3}` })),
text(groundX, y + 70, "ground", 12.5, C.ink, "middle", "700"),
text(groundX, y + 88, "grinding: half escapes fast", 10.5, C.ink2, "middle"),
el("path", { d: "M255 220 h 50 l -6 26 h -38 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("rect", { x: 259, y: 220, width: 42, height: 6, fill: C.tan }),
text(280, 258, "bloom & crema — CO₂ escaping into the cup", 11, C.ink2, "middle"),
caption("a dark roast holds ~8 liters of CO₂ per kilo — bloom and crema are its escape"),
];
},
ibColor() {
const leftX = 150, rightX = 410, beanY = 96;
const bars = (cx, vol, den) => {
const hMax = 40, y0 = 196;
const volH = (vol / 1.8) * hMax, denH = (den / 0.9) * hMax;
return [
el("rect", { x: cx - 34, y: y0 + hMax - volH, width: 22, height: volH, rx: 3, fill: C.ember }),
text(cx - 23, y0 + hMax + 14, `${vol}×`, 10.5, C.ink2, "middle"),
el("rect", { x: cx + 12, y: y0 + hMax - denH, width: 22, height: denH, rx: 3, fill: C.water }),
text(cx + 23, y0 + hMax + 14, `${den}`, 10.5, C.ink2, "middle"),
];
};
return [
bean(leftX, beanY, 1.4, C.brown),
bean(rightX, beanY, 1.4, C.brown),
text(280, beanY + 6, "=", 26, C.pass, "middle", "700"),
text(leftX, beanY + 62, "fast & hot", 12.5, C.ink, "middle", "700"),
text(rightX, beanY + 62, "slow & low", 12.5, C.ink, "middle", "700"),
text(leftX - 23, 190, "vol", 10, C.ink2, "middle"),
text(leftX + 23, 190, "den", 10, C.ink2, "middle"),
text(rightX - 23, 190, "vol", 10, C.ink2, "middle"),
text(rightX + 23, 190, "den", 10, C.ink2, "middle"),
...bars(leftX, 1.7, 0.62),
...bars(rightX, 1.4, 0.75),
text(280, 220, "≠", 22, C.fail, "middle", "700"),
caption("equal color can hide different chemistry — that's why we log the whole curve"),
];
},
// ── Roasting by the senses ──
snInstruments() {
return [
senseIcon("nose", 160, 70, C.ink2),
senseIcon("eye", 280, 70, C.ink2),
senseIcon("ear", 400, 70, C.ink2),
text(160, 100, "smell", 11, C.ink2, "middle"),
text(280, 100, "sight", 11, C.ink2, "middle"),
text(400, 100, "hearing", 11, C.ink2, "middle"),
el("rect", { x: 271, y: 122, width: 18, height: 66, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
el("circle", { cx: 280, cy: 196, r: 13, fill: C.ember }),
el("rect", { x: 276, y: 130, width: 8, height: 56, rx: 4, fill: C.ember, class: "ac-fill-up" }),
el("rect", { x: 330, y: 150, width: 150, height: 30, rx: 8, fill: C.emberSoft, stroke: C.ember, "stroke-width": 1.4 }),
text(405, 170, "±50°F between machines", 11, C.ember, "middle", "700"),
...stageStrip([
{ x: 60, w: 140, color: C.yellow, label: "drying" },
{ x: 210, w: 140, color: C.tan, label: "Maillard" },
{ x: 360, w: 140, color: C.brown, label: "development" },
]),
caption("probes disagree machine to machine — your senses read the beans directly"),
];
},
snGrass() {
return [
bean(170, 150, 1.9, C.green, { class: "ac-grass-fade" }),
steamWisps(170, 92, 3),
text(170, 60, "steam — not smoke", 11.5, C.ink2, "middle"),
text(360, 110, "cut grass", 13, C.pass, "start", "700"),
text(360, 150, "wet hay,", 13, C.tan, "start", "700"),
text(360, 170, "damp grain", 13, C.tan, "start", "700"),
caption("early aromas ride on visible steam: cut grass, then wet hay and damp grain"),
];
},
snHinge() {
return [
senseIcon("nose", 280, 66, C.ink2),
bean(280, 150, 1.7, C.yellow, { class: "ac-breathe" }),
el("g", { class: "ac-cross-a" }, text(280, 222, "drying hay", 14, C.ink2, "middle", "700")),
el("g", { class: "ac-cross-b" }, text(280, 222, "baking biscuits", 14, C.ember, "middle", "700")),
text(150, 150, "≈330 °F", 12.5, C.ink, "middle", "700"),
...stageStrip(
[
{ x: 90, w: 190, color: C.yellow, label: "drying" },
{ x: 280, w: 190, color: C.tan, label: "Maillard" },
],
{ markerX: 280, markerColor: C.ember, markerCls: "ac-marker-cross" },
),
caption("the ten-second moment barn smell turns bakery — roasters mark it by nose"),
];
},
snToast() {
const items = [["toast", 210, 0], ["nuts", 170, 1], ["caramel", 130, 2]];
return [
bean(160, 190, 1.7, C.tan, { class: "ac-maillard-shift" }),
steamWisps(160, 128, 2),
text(160, 92, "the smell dries out", 11.5, C.ink2, "middle"),
...items.map(([label, y, i]) =>
el("g", { class: `ac-rise ac-delay-${i}` }, text(360, y, label, 14, i === 2 ? C.ember : C.tan, "middle", "700")),
),
el("line", { x1: 300, y1: 230, x2: 420, y2: 230, stroke: C.line, "stroke-width": 1.5 }),
text(360, 246, "≈370 °F — sugars melt", 11.5, C.ink2, "middle"),
caption("toast, then nuts, then caramel — and the smell itself turns from damp to dry"),
];
},
snSight() {
const stages = [
["green", C.green, false],
["pale yellow", C.yellow, false],
["marbled tan", "#c9a468", false],
["brown", C.brown, false],
["expanded", "#9c6a3a", true],
];
const xs = [90, 190, 290, 390, 490];
const out = [];
stages.forEach(([label, color, cracked], i) => {
out.push(bean(xs[i], 130, 1.05, color, cracked ? { crack: true } : {}));
out.push(text(xs[i], 190, label, 10.5, C.ink2, "middle"));
});
out.push(...[0, 1].map((j) => el("path", { d: `M${xs[4] + 22 + j * 6} ${118 - j * 8} q 8 3 12 -3`, stroke: C.tan, "stroke-width": 2, fill: "none", class: `ac-drift ac-delay-${j}` })));
out.push(text(290, 208, "mottled at light roast = normal", 11.5, C.ember, "middle", "700"));
out.push(caption("yellow, marbled, brown; the crease stays shut until first crack opens it"));
return out;
},
snFirstcrack() {
const baseY = 170;
const spikes = [80, 150, 230, 340, 420];
return [
senseIcon("ear", 280, 60, C.ink2),
el("line", { x1: 50, y1: baseY, x2: 510, y2: baseY, stroke: C.line, "stroke-width": 1.5 }),
...spikes.map((x, i) =>
el("line", { x1: x, y1: baseY, x2: x, y2: baseY - 46 - (i % 2) * 10, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i % 3}` }),
),
text(280, baseY + 24, "~800 Hz, loud, discrete — popcorn", 11.5, C.ink2, "middle"),
el("rect", { x: 55, y: 200, width: 450, height: 30, rx: 8, fill: C.paper, stroke: C.line }),
text(280, 219, "call it on 35 rapid pops, not the first tick", 10, C.ink, "middle", "700"),
text(280, 248, "fewer than 1 in 10 beans pops audibly", 11.5, C.ember, "middle", "700"),
caption("sharp, low, popcorn pops — mark first crack at 35 in quick succession"),
];
},
snSecondcrack() {
const baseY = 170;
const spikes = [...Array(9)].map((_, i) => 60 + i * 32);
return [
bean(430, 150, 1.4, C.dark, { crack: true }),
el("circle", { cx: 424, cy: 140, r: 4, fill: "#f0d9a0", opacity: 0.85 }),
text(430, 210, "dry, brittle, oily", 11, C.ink2, "middle"),
el("line", { x1: 40, y1: baseY, x2: 340, y2: baseY, stroke: C.line, "stroke-width": 1.5 }),
...spikes.map((x, i) =>
el("line", { x1: x, y1: baseY, x2: x, y2: baseY - 18 - (i % 2) * 6, stroke: C.ember, "stroke-width": 2, "stroke-linecap": "round", class: `ac-pulse ac-delay-${i % 3}` }),
),
text(220, baseY + 24, "~19× higher, 5× faster, quieter", 10, C.ink2, "middle"),
text(220, baseY + 40, "rice krispies · electrical sparking", 10.5, C.ink2, "middle"),
caption("a fast, shallow crackle from a dry, brittle bean — gas fracture, not steam pop"),
];
},
snArcs() {
const X0 = 60, X1 = 500, Y0 = 70, YB = 210;
return [
el("path", { d: `M${X0} ${YB} H ${X1} M${X0} ${YB} V ${Y0}`, stroke: C.line, "stroke-width": 1.5, fill: "none" }),
arcCurve([[X0, Y0 + 10], [180, 90], [340, 140], [X1, 170]], C.water, "ac-draw"),
text(70, 82, "acidity", 11, C.water, "start", "700"),
arcCurve([[X0, YB - 20], [220, Y0 + 5], [320, Y0 + 15], [X1, YB - 40]], C.tan, "ac-draw"),
text(210, Y0 - 6, "sweetness", 11, C.tan, "middle", "700"),
arcCurve([[X0, YB - 60], [220, YB - 100], [360, YB - 90], [X1, YB - 130]], C.brown, "ac-draw"),
text(360, YB - 96, "body", 11, C.brown, "start", "700"),
arcCurve([[X0, YB - 4], [300, YB - 10], [400, YB - 40], [X1, Y0 + 4]], C.fail, "ac-draw"),
text(430, YB - 34, "bitterness", 11, C.fail, "start", "700"),
...stageStrip([
{ x: X0, w: 146, color: C.yellow, label: "drying" },
{ x: X0 + 146, w: 146, color: C.tan, label: "Maillard" },
{ x: X0 + 292, w: 148, color: C.brown, label: "development" },
], { markerX: 300, markerColor: C.ember, markerCls: "ac-slide-x" }),
caption("acidity only falls · sweetness peaks · body rounds over · bitterness arrives"),
];
},
snDroplevels() {
const stages = [
["City", "#c9a468", "bright·juicy"],
["City+", "#b98d54", "balance point"],
["Full City", "#96652f", "bittersweet"],
["FC+", "#7d4f26", "espresso"],
["Vienna", "#5f3a1c", "eclipses origin"],
["French", "#3c2413", "thin & smoky"],
];
const xs = [72, 158, 244, 330, 416, 502];
const out = [];
stages.forEach(([label, color, tag], i) => {
out.push(bean(xs[i], 120, 0.85, color));
out.push(text(xs[i], 168, label, 10.5, C.ink, "middle", "700"));
out.push(text(xs[i], 182, tag, 9.5, C.ink2, "middle"));
});
out.push(caption("the same coffee, six different cups — the drop decides which one you drink"));
return out;
},
snSmoke() {
const stages = [
[110, 0.4, C.ink2, "steam"],
[280, 0.6, "#a89e8f", "thin smoke"],
[450, 0.9, "#3a332c", "heavy smoke"],
];
const out = [];
stages.forEach(([x, op, color, label], i) => {
out.push(...[0, 1, 2].map((j) =>
el("path", {
d: `M${x + (j - 1) * 16} 200 c -6 -14, 6 -22, 0 -36 c -5 -12, 5 -20, 0 -34`,
fill: "none", stroke: color, "stroke-width": 3 + i * 1.5, "stroke-linecap": "round",
class: `ac-steam ac-delay-${j}`, opacity: op,
}),
));
out.push(text(x, 224, label, 12, color === C.ink2 ? C.ink2 : color, "middle", "700"));
});
out.push(text(80, 250, "pre-smoke = ~30s warn", 10, C.ink2, "start"));
out.push(text(280, 250, "heavy smoke = stop", 10.5, C.fail, "middle", "700"));
out.push(text(500, 250, "acrid next day = too hot", 10, C.ink2, "end"));
out.push(caption("the whole aroma timeline in four words: steam slowly becomes smoke"));
return out;
},
// ── When roasts go wrong ──
wwGood() {
return [
el("path", { d: "M60 220 H 500 M60 220 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
text(52, 70, "RoR", 11, C.ink2, "end"),
arcCurve([[64, 84], [160, 118], [280, 150], [400, 176], [494, 196]], C.pass, "ac-draw"),
...["even color", "interior matches exterior", "weight loss on target", "sweetness survives cooling"].map((item, i) =>
el("g", {},
el("circle", { cx: 340, cy: 60 + i * 28, r: 7, fill: C.pass }),
el("path", { d: `M336 ${60 + i * 28} l 3 3 l 5 -6`, stroke: C.paper, "stroke-width": 1.6, fill: "none" }),
text(354, 65 + i * 28, item, 11, C.ink2, "start"),
),
),
caption("a smoothly falling RoR, even color, matched core & surface, a cup that lasts"),
];
},
wwUnderdev() {
return [
halfBean(160, 150, 1.7, {
shellFill: C.brown, strokeWidth: 3,
children: [el("circle", { cx: 0, cy: 6, r: 26, fill: "#e8d9b0" })],
}),
text(160, 232, "dark outside, pale inside", 11.5, C.ink2, "middle"),
el("path", { d: "M340 220 H 500 M340 220 V 100", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
arcCurve([[344, 130], [400, 170], [440, 196]], C.fail, "ac-draw"),
el("line", { x1: 440, y1: 100, x2: 440, y2: 220, stroke: C.ink2, "stroke-width": 1.4, "stroke-dasharray": "3 3" }),
text(440, 92, "dropped early", 10.5, C.ink2, "middle"),
text(420, 240, "grassy · sour · papery", 12, C.fail, "middle", "700"),
caption("brown outside, raw inside — heat never reached the core"),
];
},
wwBaked() {
return [
bean(150, 140, 1.8, C.brown),
text(150, 210, "looks fine!", 12.5, C.pass, "middle", "700"),
el("path", { d: "M330 220 H 500 M330 220 V 90", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
arcCurve([[334, 110], [380, 140], [420, 148], [470, 150], [496, 152]], C.fail, "ac-draw"),
el("path", { d: "M334 110 L400 140 L496 175", fill: "none", stroke: C.pass, "stroke-width": 2.2, "stroke-dasharray": "6 5" }),
text(415, 100, "stalled — flat", 11, C.fail, "middle", "700"),
text(280, 240, "flavor fades as it cools", 12.5, C.ink2, "middle"),
caption("a stalled roast spends the sugars without buying the flavor — and looks normal"),
];
},
wwCrashflick() {
return [
el("path", { d: "M60 230 H 500 M60 230 V 70", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
text(52, 84, "RoR", 11, C.ink2, "end"),
arcCurve([[64, 96], [180, 140], [260, 158], [300, 210], [340, 168], [400, 176], [494, 200]], C.fail, "ac-draw"),
el("circle", { cx: 300, cy: 210, r: 5.5, fill: C.fail, class: "ac-pulse" }),
text(300, 228, "crash", 11.5, C.fail, "middle", "700"),
text(280, 248, "steam dump + heat cut too late", 10.5, C.ink2, "middle"),
el("circle", { cx: 340, cy: 168, r: 5.5, fill: C.fail, class: "ac-pulse ac-delay-1" }),
text(360, 150, "flick", 11.5, C.fail, "middle", "700"),
text(420, 96, "gas added in a panic", 10.5, C.ink2, "middle"),
text(190, 96, "fix: smaller cuts,", 10.5, C.ember, "middle", "700"),
text(190, 110, "~45s BEFORE crack", 10.5, C.ember, "middle", "700"),
caption("the V-shape around first crack — crash bakes the cup, flick sears it roasty"),
];
},
wwScorch() {
const items = [
["scorch", 130, C.tan, "early, drum too hot"],
["tipping", 280, "#6b4423", "too much heat, soft"],
["facing", 430, C.dark, "late, held on metal"],
];
const out = [];
items.forEach(([label, x, color, sub]) => {
out.push(bean(x, 130, 1.3, color));
if (label === "scorch")
out.push(...[0, 1, 2].map((j) => el("circle", { cx: x - 10 + j * 10, cy: 118 + (j % 2) * 6, r: 2.2, fill: C.dark })));
if (label === "tipping")
out.push(el("path", { d: `M${x} 96 q 6 -10 0 -18 q -6 8 0 18`, fill: C.dark }));
if (label === "facing")
out.push(el("ellipse", { cx: x, cy: 130, rx: 22, ry: 30, fill: C.dark, opacity: 0.75 }));
out.push(text(x, 190, label, 12.5, C.ink, "middle", "700"));
out.push(text(x, 206, sub, 10, C.ink2, "middle"));
});
out.push(text(280, 240, "burnt · ashy · smoky over a hollow cup", 12, C.fail, "middle", "700"));
out.push(caption("three marks, one story: the surface got hotter than the bean could carry"));
return out;
},
wwFast() {
return [
halfBean(160, 150, 1.8, {
shellFill: C.dark, strokeWidth: 3,
children: [el("circle", { cx: 4, cy: 10, r: 24, fill: "#dccfa8" })],
}),
text(160, 236, "char over raw", 11.5, C.ink2, "middle"),
el("path", { d: "M330 220 H 500 M330 220 V 70", stroke: C.line, "stroke-width": 1.3, fill: "none" }),
arcCurve([[334, 210], [360, 90], [390, 130], [430, 170], [470, 196], [496, 206]], C.fail, "ac-draw"),
text(360, 78, "towering, early", 10.5, C.ink2, "middle"),
text(460, 240, "burnt AND grassy", 12, C.fail, "middle", "700"),
caption("the one defect no drop change fixes — outside charred while inside dried"),
];
},
wwQuakers() {
const xs = [90, 150, 210, 270, 330, 390, 450];
const paleIdx = new Set([2, 5]);
const out = [];
xs.forEach((x, i) => {
const pale = paleIdx.has(i);
out.push(bean(x, 150, 0.95, pale ? "#d8c58c" : C.brown, {}));
if (pale) out.push(el("circle", { cx: x, cy: 150, r: 30, fill: "none", stroke: C.ember, "stroke-width": 2, class: "ac-ring" }));
});
out.push(
el("g", { class: "ac-tamp" },
el("path", { d: "M210 70 L 200 118 M210 70 L 220 118", stroke: C.ink2, "stroke-width": 3, "stroke-linecap": "round", fill: "none" }),
),
);
out.push(text(280, 200, "unripe: no sugar = no browning fuel", 11.5, C.ink2, "middle"));
out.push(caption("no sugars, no Maillard — color is chemistry, and these beans came without fuel"));
return out;
},
wwUneven() {
const colors = ["#cdbd8f", "#c2a468", "#b98d54", "#a5713a", "#8a5a30", "#6b4423", "#54371f"];
const xs = colors.map((_, i) => 80 + i * 62);
const out = colors.map((c, i) => bean(xs[i], 120, 0.9, c));
out.push(
text(150, 190, "few pale outliers", 11.5, C.ink2, "middle", "700"),
text(150, 206, "→ quakers (green's fault)", 10.5, C.ink2, "middle"),
text(430, 190, "continuous spread", 11.5, C.ink2, "middle", "700"),
text(430, 206, "→ moisture, drum, airflow", 10.5, C.ink2, "middle"),
caption("read the pattern: outliers are the green's fault, a spread is the roast's"),
);
return out;
},
wwAirflow() {
return [
el("circle", { cx: 200, cy: 130, r: 74, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
...[0, 1, 2].map((i) => el("path", { d: `M${140 + i * 60} 210 v 30`, stroke: C.water, "stroke-width": 3, "stroke-linecap": "round", class: `ac-rise ac-delay-${i}` })),
text(200, 258, "airflow", 11, C.water, "middle"),
el("g", { class: "ac-tumble" }, bean(180, 116, 0.5, C.tan), bean(220, 132, 0.5, C.brown), bean(196, 150, 0.5, C.tan)),
text(400, 90, "too little →", 11.5, C.fail, "start", "700"),
text(400, 106, "smoke settles, ashy,", 10.5, C.ink2, "start"),
text(400, 120, "chaff fire risk", 10.5, C.ink2, "start"),
text(400, 150, "too much →", 11.5, C.fail, "start", "700"),
text(400, 166, "strips aromatics,", 10.5, C.ink2, "start"),
text(400, 180, "stalls the roast", 10.5, C.ink2, "start"),
...stageStrip([
{ x: 60, w: 150, color: C.yellow, label: "low: drying" },
{ x: 214, w: 150, color: C.tan, label: "medium: browning" },
{ x: 368, w: 132, color: C.ember, label: "high: crack→drop" },
]),
caption("most drum heat rides the air — low drying, medium browning, high through crack"),
];
},
wwDiagnose() {
const rows = [
["sour & thin", "→ more development", 0],
["flat & cardboard", "→ keep momentum, don't stall", 1],
["smoky & dull", "→ fix crash-flick, add air", 2],
];
const out = [];
rows.forEach(([taste, fix, i], idx) => {
const y = 76 + idx * 46;
out.push(
el("rect", { x: 60, y: y - 22, width: 440, height: 38, rx: 8, fill: C.emberSoft, class: `ac-row-glow ac-delay-${i}` }),
text(80, y, taste, 12.5, C.ink, "start", "700"),
text(280, y, fix, 12, C.ember, "start"),
);
});
out.push(text(280, 236, "one change per batch — and check your brew first", 12.5, C.ink, "middle", "700"));
out.push(caption("the cup names the fix — change one thing, rule out the brew before the roast"));
return out;
},
};