Files
roast_command_center/public/js/academy-scenes.js
T
snowspeederandClaude Fable 5 385474ecef
Test and deploy / test-and-deploy (push) Successful in 1m8s
Removable reference curves, audit fixes across pages, shaded Academy art
The planner's Artisan .alog drawer now shows what's attached with a
"Remove reference curve" control (re-rendered per drawer open), so adding
a reference curve is no longer a one-way door; /api/alog shares the 8 MB
body cap so real-sized logs parse instead of failing with "bad_request".

Deep-evaluation fixes: editing a brew of an archived bean no longer
silently detaches the bean; the roasts pending-review poll no longer
wipes in-progress after-roast edits; roasters gain an Edit (rename/model)
action; the gear page refuses to autosave over a failed load; duplicating
a plan carries its custom name; cupping sessions can attach a plan after
creation (ownership-checked PUT + selector); admin user deletion also
refreshes plans/audit; cupping cup-count subtitle stays live; roasts
error-row colspan corrected. Regression tests cover the new cupping PUT
and the /api/alog body cap.

Academy scenes drop the flat paper-cutout look: shared defs provide
radial-gradient shading on every bean/half-bean/particle, flame gradients
with radiant halos, soft ground shadows, and a warm-lit stage background;
fill-shift animations now ride a partial-opacity tint overlay so shading
survives the color change.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-11 16:59:28 -04:00

1303 lines
62 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;
}
// ── Shading helpers ────────────────────────────────────────────────────────
// Flat fills read as paper cutouts; every solid body (beans, flames, liquids)
// instead gets a radial gradient built from its base color — warm-white toward
// the light, warm-black away from it — so the shapes model light like objects.
const hexToRgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
const mixHex = (hex, target, f) =>
`#${hexToRgb(hex)
.map((c, i) => Math.round(c + (target[i] - c) * f).toString(16).padStart(2, "0"))
.join("")}`;
const tint = (hex, f) => mixHex(hex, [255, 250, 240], f);
const shade = (hex, f) => mixHex(hex, [30, 18, 10], f);
let gradSeq = 0;
/** Per-instance radial gradient for a body color; returns [defNode, fillUrl].
* gradientUnits stay objectBoundingBox, so one def can shade many siblings. */
function radialFill(fill, opts = {}) {
const id = `ac-rg-${gradSeq++}`;
const node = el(
"radialGradient",
{ id, cx: opts.cx ?? "36%", cy: opts.cy ?? "30%", r: opts.r ?? "85%" },
el("stop", { offset: "0%", "stop-color": tint(fill, opts.tint ?? 0.3) }),
el("stop", { offset: "55%", "stop-color": fill }),
el("stop", { offset: "100%", "stop-color": shade(fill, opts.shade ?? 0.3) }),
);
return [node, `url(#${id})`];
}
/** Shared gradient/glow definitions every scene can reference by id.
* Appended once per slide render (the SVG is rebuilt each slide). */
export function sceneDefs() {
return el(
"defs",
{},
el(
"radialGradient",
{ id: "ac-ground", cx: "50%", cy: "50%", r: "50%" },
el("stop", { offset: "0%", "stop-color": "#1b1614", "stop-opacity": 0.16 }),
el("stop", { offset: "100%", "stop-color": "#1b1614", "stop-opacity": 0 }),
),
el(
"radialGradient",
{ id: "ac-halo", cx: "50%", cy: "50%", r: "50%" },
el("stop", { offset: "0%", "stop-color": "#e8892c", "stop-opacity": 0.38 }),
el("stop", { offset: "100%", "stop-color": "#e8892c", "stop-opacity": 0 }),
),
el(
"linearGradient",
{ id: "ac-flame-grad", x1: "0", y1: "0", x2: "0", y2: "1" },
el("stop", { offset: "0%", "stop-color": "#e8952f" }),
el("stop", { offset: "100%", "stop-color": "#9c3a10" }),
),
el(
"linearGradient",
{ id: "ac-flame-core", x1: "0", y1: "0", x2: "0", y2: "1" },
el("stop", { offset: "0%", "stop-color": "#f7e3a0" }),
el("stop", { offset: "100%", "stop-color": "#e0a83c" }),
),
);
}
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 [grad, gradUrl] = radialFill(fill);
const inner = el("g", { class: opts.class ?? "" });
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: gradUrl }));
// Fill-shift animations (drying, Maillard, …) animate a flat overlay riding at partial
// opacity ABOVE the gradient, so the color changes while the 3-D shading survives.
if (/ac-(dry-shift|maillard-shift|grass-fade)/.test(opts.class ?? ""))
inner.append(el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill, class: "ac-tint" }));
inner.append(
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.26)" }),
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
);
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",
}),
);
const outer = el("g", { transform: `translate(${x} ${y}) scale(${scale})` });
if (opts.shadow !== false)
outer.append(el("ellipse", { cx: 0, cy: 45, rx: 24, ry: 6, fill: "url(#ac-ground)" }));
outer.append(grad, inner);
return outer;
}
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.
// A soft radial halo behind the flame reads as radiant heat.
return el("g", { transform: `translate(${x} ${y}) scale(${scale})` },
el("ellipse", { cx: 0, cy: -14, rx: 22, ry: 26, fill: "url(#ac-halo)" }),
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: "url(#ac-flame-grad)" }),
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: "url(#ac-flame-core)" }),
),
);
}
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 [grad, gradUrl] = radialFill(opts.shellFill ?? C.tan);
const shell = el("path", {
d: shapeD,
fill: gradUrl,
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})` },
el("ellipse", { cx: 0, cy: 66, rx: 44, ry: 8, fill: "url(#ac-ground)" }),
clip,
grad,
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,
"stroke-linecap": "round",
"stroke-linejoin": "round",
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;
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 [jbGrad, jbUrl] = radialFill(C.green);
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. The gradient
// base carries the shading; the animated flat overlay above it carries the color.
el("g", { transform: `translate(${cx} ${cy}) scale(2.15)` },
jbGrad,
el("g", { class: "ac-jb-grow" },
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill: jbUrl }),
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.26)" }),
el("ellipse", { cx: -11, cy: -16, rx: 6.5, ry: 11, fill: "rgba(255,252,245,0.16)", transform: "rotate(-16 -11 -16)" }),
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;
const [outerGrad, outerUrl] = radialFill(C.green);
const [innerGrad, innerUrl] = radialFill("#8ea06f", { tint: 0.2, shade: 0.18 });
return [
outerGrad,
innerGrad,
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: outerUrl, class: "ac-breathe" }),
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: innerUrl }),
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, { shadow: false }), bean(172, 132, 0.5, C.green, { shadow: false }),
bean(140, 162, 0.5, "#8ea06f", { shadow: false }), bean(182, 168, 0.5, C.green, { shadow: false })),
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() {
const [grad, gradUrl] = radialFill(C.brown);
return [
grad,
el("circle", { cx: 150, cy: 140, r: 52, fill: gradUrl }),
el("ellipse", { cx: 150, cy: 206, rx: 48, ry: 9, fill: "url(#ac-ground)" }),
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() {
// One gradient def serves every particle (objectBoundingBox units shade each circle).
const [grindGrad, grindUrl] = radialFill(C.brown);
const pile = (x, label, sub, circles) => {
const g = el("g", {});
g.append(el("ellipse", { cx: x, cy: 172, rx: 52, ry: 8, fill: "url(#ac-ground)" }));
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: grindUrl }));
g.append(text(x, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
return g;
};
return [
grindGrad,
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, { shadow: false }),
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;
},
// ── 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, { shadow: false }), bean(220, 132, 0.5, C.brown, { shadow: false }), bean(196, 150, 0.5, C.tan, { shadow: false })),
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;
},
};