diff --git a/README.md b/README.md index ab322c2..decb6ee 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ A fillable, live-computing web version of the manual coffee roast plan worksheet it back transactionally (delete-and-restore, refuses backups with no active admin, keeps the importing admin's session when possible). Users can download their own data from the Account page. +- **Academy** — `/academy` teaches roasting and brewing as narrated, animated slide lessons: + green-bean physics through every roast stage (drying, Maillard, first crack, development, + curve reading, defects) and extraction plus technique for every brewer family. Narration is + Azure TTS (`en-US-Andrew` HD voice), pre-generated to committed MP3s by + `scripts/generate-academy-audio.mjs` (reads `AZURE_VOICE_KEY`/`AZURE_VOICE` from `.env`, + region eastus — run it locally after editing lesson scripts; production never needs the + key). Slides carry display transcripts as captions and phonetic respellings for the voice. - **Live ledger** — the worksheet's time-ledger math (first crack, yellow, Maillard, development, drop, and the four sanity-check ratios) recomputes as you type, using the exact same arithmetic as the paper worksheet (verified against both its worked examples). diff --git a/public/academy-audio/cv-anatomy.mp3 b/public/academy-audio/cv-anatomy.mp3 new file mode 100644 index 0000000..c93cb5b Binary files /dev/null and b/public/academy-audio/cv-anatomy.mp3 differ diff --git a/public/academy-audio/cv-crash.mp3 b/public/academy-audio/cv-crash.mp3 new file mode 100644 index 0000000..437a379 Binary files /dev/null and b/public/academy-audio/cv-crash.mp3 differ diff --git a/public/academy-audio/cv-decline.mp3 b/public/academy-audio/cv-decline.mp3 new file mode 100644 index 0000000..19a991f Binary files /dev/null and b/public/academy-audio/cv-decline.mp3 differ diff --git a/public/academy-audio/cv-stall.mp3 b/public/academy-audio/cv-stall.mp3 new file mode 100644 index 0000000..009f712 Binary files /dev/null and b/public/academy-audio/cv-stall.mp3 differ diff --git a/public/academy-audio/df-fast.mp3 b/public/academy-audio/df-fast.mp3 new file mode 100644 index 0000000..4ffd242 Binary files /dev/null and b/public/academy-audio/df-fast.mp3 differ diff --git a/public/academy-audio/df-onechange.mp3 b/public/academy-audio/df-onechange.mp3 new file mode 100644 index 0000000..190389a Binary files /dev/null and b/public/academy-audio/df-onechange.mp3 differ diff --git a/public/academy-audio/df-slow.mp3 b/public/academy-audio/df-slow.mp3 new file mode 100644 index 0000000..1e24594 Binary files /dev/null and b/public/academy-audio/df-slow.mp3 differ diff --git a/public/academy-audio/df-surface.mp3 b/public/academy-audio/df-surface.mp3 new file mode 100644 index 0000000..45212d5 Binary files /dev/null and b/public/academy-audio/df-surface.mp3 differ diff --git a/public/academy-audio/dry-charge.mp3 b/public/academy-audio/dry-charge.mp3 new file mode 100644 index 0000000..0be5bb1 Binary files /dev/null and b/public/academy-audio/dry-charge.mp3 differ diff --git a/public/academy-audio/dry-color.mp3 b/public/academy-audio/dry-color.mp3 new file mode 100644 index 0000000..653b115 Binary files /dev/null and b/public/academy-audio/dry-color.mp3 differ diff --git a/public/academy-audio/dry-endo.mp3 b/public/academy-audio/dry-endo.mp3 new file mode 100644 index 0000000..a3aad38 Binary files /dev/null and b/public/academy-audio/dry-endo.mp3 differ diff --git a/public/academy-audio/dry-share.mp3 b/public/academy-audio/dry-share.mp3 new file mode 100644 index 0000000..3193883 Binary files /dev/null and b/public/academy-audio/dry-share.mp3 differ diff --git a/public/academy-audio/es-dial.mp3 b/public/academy-audio/es-dial.mp3 new file mode 100644 index 0000000..3708675 Binary files /dev/null and b/public/academy-audio/es-dial.mp3 differ diff --git a/public/academy-audio/es-moka.mp3 b/public/academy-audio/es-moka.mp3 new file mode 100644 index 0000000..c54d4ef Binary files /dev/null and b/public/academy-audio/es-moka.mp3 differ diff --git a/public/academy-audio/es-principle.mp3 b/public/academy-audio/es-principle.mp3 new file mode 100644 index 0000000..7c807ad Binary files /dev/null and b/public/academy-audio/es-principle.mp3 differ diff --git a/public/academy-audio/es-puck.mp3 b/public/academy-audio/es-puck.mp3 new file mode 100644 index 0000000..25ed42e Binary files /dev/null and b/public/academy-audio/es-puck.mp3 differ diff --git a/public/academy-audio/es-shot.mp3 b/public/academy-audio/es-shot.mp3 new file mode 100644 index 0000000..dc0f80d Binary files /dev/null and b/public/academy-audio/es-shot.mp3 differ diff --git a/public/academy-audio/ex-dissolve.mp3 b/public/academy-audio/ex-dissolve.mp3 new file mode 100644 index 0000000..ff7d16f Binary files /dev/null and b/public/academy-audio/ex-dissolve.mp3 differ diff --git a/public/academy-audio/ex-grind.mp3 b/public/academy-audio/ex-grind.mp3 new file mode 100644 index 0000000..77523b0 Binary files /dev/null and b/public/academy-audio/ex-grind.mp3 differ diff --git a/public/academy-audio/ex-taste.mp3 b/public/academy-audio/ex-taste.mp3 new file mode 100644 index 0000000..050d8bb Binary files /dev/null and b/public/academy-audio/ex-taste.mp3 differ diff --git a/public/academy-audio/ex-temp-time.mp3 b/public/academy-audio/ex-temp-time.mp3 new file mode 100644 index 0000000..13833c9 Binary files /dev/null and b/public/academy-audio/ex-temp-time.mp3 differ diff --git a/public/academy-audio/ex-yield.mp3 b/public/academy-audio/ex-yield.mp3 new file mode 100644 index 0000000..081371f Binary files /dev/null and b/public/academy-audio/ex-yield.mp3 differ diff --git a/public/academy-audio/fc-chemistry.mp3 b/public/academy-audio/fc-chemistry.mp3 new file mode 100644 index 0000000..9d2f923 Binary files /dev/null and b/public/academy-audio/fc-chemistry.mp3 differ diff --git a/public/academy-audio/fc-drop.mp3 b/public/academy-audio/fc-drop.mp3 new file mode 100644 index 0000000..73b629d Binary files /dev/null and b/public/academy-audio/fc-drop.mp3 differ diff --git a/public/academy-audio/fc-dtr.mp3 b/public/academy-audio/fc-dtr.mp3 new file mode 100644 index 0000000..ed8d6bf Binary files /dev/null and b/public/academy-audio/fc-dtr.mp3 differ diff --git a/public/academy-audio/fc-exotherm.mp3 b/public/academy-audio/fc-exotherm.mp3 new file mode 100644 index 0000000..70ef63f Binary files /dev/null and b/public/academy-audio/fc-exotherm.mp3 differ diff --git a/public/academy-audio/fc-pressure.mp3 b/public/academy-audio/fc-pressure.mp3 new file mode 100644 index 0000000..783ba9c Binary files /dev/null and b/public/academy-audio/fc-pressure.mp3 differ diff --git a/public/academy-audio/gb-composition.mp3 b/public/academy-audio/gb-composition.mp3 new file mode 100644 index 0000000..5599cca Binary files /dev/null and b/public/academy-audio/gb-composition.mp3 differ diff --git a/public/academy-audio/gb-density.mp3 b/public/academy-audio/gb-density.mp3 new file mode 100644 index 0000000..82eecae Binary files /dev/null and b/public/academy-audio/gb-density.mp3 differ diff --git a/public/academy-audio/gb-mission.mp3 b/public/academy-audio/gb-mission.mp3 new file mode 100644 index 0000000..fc3e72a Binary files /dev/null and b/public/academy-audio/gb-mission.mp3 differ diff --git a/public/academy-audio/gb-seed.mp3 b/public/academy-audio/gb-seed.mp3 new file mode 100644 index 0000000..c5d76de Binary files /dev/null and b/public/academy-audio/gb-seed.mp3 differ diff --git a/public/academy-audio/im-aeropress.mp3 b/public/academy-audio/im-aeropress.mp3 new file mode 100644 index 0000000..9690c9f Binary files /dev/null and b/public/academy-audio/im-aeropress.mp3 differ diff --git a/public/academy-audio/im-coldcup.mp3 b/public/academy-audio/im-coldcup.mp3 new file mode 100644 index 0000000..869e4ab Binary files /dev/null and b/public/academy-audio/im-coldcup.mp3 differ diff --git a/public/academy-audio/im-frenchpress.mp3 b/public/academy-audio/im-frenchpress.mp3 new file mode 100644 index 0000000..199dcf4 Binary files /dev/null and b/public/academy-audio/im-frenchpress.mp3 differ diff --git a/public/academy-audio/im-principle.mp3 b/public/academy-audio/im-principle.mp3 new file mode 100644 index 0000000..889b159 Binary files /dev/null and b/public/academy-audio/im-principle.mp3 differ diff --git a/public/academy-audio/im-switch.mp3 b/public/academy-audio/im-switch.mp3 new file mode 100644 index 0000000..be70657 Binary files /dev/null and b/public/academy-audio/im-switch.mp3 differ diff --git a/public/academy-audio/mai-expand.mp3 b/public/academy-audio/mai-expand.mp3 new file mode 100644 index 0000000..c0de526 Binary files /dev/null and b/public/academy-audio/mai-expand.mp3 differ diff --git a/public/academy-audio/mai-momentum.mp3 b/public/academy-audio/mai-momentum.mp3 new file mode 100644 index 0000000..b13ac02 Binary files /dev/null and b/public/academy-audio/mai-momentum.mp3 differ diff --git a/public/academy-audio/mai-pace.mp3 b/public/academy-audio/mai-pace.mp3 new file mode 100644 index 0000000..8f23865 Binary files /dev/null and b/public/academy-audio/mai-pace.mp3 differ diff --git a/public/academy-audio/mai-reaction.mp3 b/public/academy-audio/mai-reaction.mp3 new file mode 100644 index 0000000..fbfc477 Binary files /dev/null and b/public/academy-audio/mai-reaction.mp3 differ diff --git a/public/academy-audio/pc-batch.mp3 b/public/academy-audio/pc-batch.mp3 new file mode 100644 index 0000000..4cafb68 Binary files /dev/null and b/public/academy-audio/pc-batch.mp3 differ diff --git a/public/academy-audio/pc-bloom.mp3 b/public/academy-audio/pc-bloom.mp3 new file mode 100644 index 0000000..f4a619a Binary files /dev/null and b/public/academy-audio/pc-bloom.mp3 differ diff --git a/public/academy-audio/pc-flat.mp3 b/public/academy-audio/pc-flat.mp3 new file mode 100644 index 0000000..0cbbe0a Binary files /dev/null and b/public/academy-audio/pc-flat.mp3 differ diff --git a/public/academy-audio/pc-principle.mp3 b/public/academy-audio/pc-principle.mp3 new file mode 100644 index 0000000..6bf505a Binary files /dev/null and b/public/academy-audio/pc-principle.mp3 differ diff --git a/public/academy-audio/pc-v60.mp3 b/public/academy-audio/pc-v60.mp3 new file mode 100644 index 0000000..a790baa Binary files /dev/null and b/public/academy-audio/pc-v60.mp3 differ diff --git a/public/academy.html b/public/academy.html new file mode 100644 index 0000000..e9895be --- /dev/null +++ b/public/academy.html @@ -0,0 +1,112 @@ + + + + + + Academy — Roast Planner + + + + + +
+ + +
+
+
+ +
+
+

Academy

+

+ Roasting and brewing, taught from the bean's point of view +

+
+
+
+
+ + +
+
+ + +
+
+
+ + + diff --git a/public/app.css b/public/app.css index 9d830bc..c43a533 100644 --- a/public/app.css +++ b/public/app.css @@ -3089,3 +3089,150 @@ select.f { margin: 2px 0; } } + +/* ── Academy ──────────────────────────────────────────────────────────── */ +.academy-lessons { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 10px; +} +.academy-lesson { + display: flex; + align-items: center; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius-md); + background: var(--surface-raised); + cursor: pointer; + font: inherit; + text-align: left; + transition: border-color 0.12s, background 0.12s; +} +.academy-lesson:hover { + border-color: var(--ember); + background: var(--ember-soft); +} +.academy-lesson-num { + flex: none; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--surface-sunken); + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: var(--ember); +} +.academy-lesson-label { + font-weight: 600; + flex: 1; +} +.academy-lesson-meta { + font-size: 11.5px; + color: var(--ink-3); + white-space: nowrap; +} +.academy-stage { + background: var(--surface); + border: 1px solid var(--line); + border-radius: var(--radius-lg); + padding: 8px; +} +.academy-stage svg { + width: 100%; + max-width: 760px; + display: block; + margin: 0 auto; +} +.academy-caption { + font-size: 15px; + line-height: 1.55; + max-width: 720px; + margin: 14px auto 10px; + min-height: 3.2em; +} +.academy-controls { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + justify-content: center; +} +.academy-dots { + display: flex; + gap: 6px; +} +.academy-dot { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1px solid var(--line-strong); + background: var(--surface-raised); + padding: 0; + cursor: pointer; +} +.academy-dot.active { + background: var(--ember); + border-color: var(--ember); +} + +/* Scene animations — restarted per slide because the SVG is rebuilt. */ +@keyframes ac-draw-kf { from { stroke-dashoffset: 900; } to { stroke-dashoffset: 0; } } +.ac-draw { stroke-dasharray: 900; animation: ac-draw-kf 2.6s ease-out forwards; } +@keyframes ac-pulse-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } } +.ac-pulse { animation: ac-pulse-kf 1.6s ease-in-out infinite; } +@keyframes ac-breathe-kf { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.045); } } +.ac-breathe { animation: ac-breathe-kf 3s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes ac-steam-kf { 0% { transform: translateY(6px); opacity: 0; } 30% { opacity: 0.55; } 100% { transform: translateY(-16px); opacity: 0; } } +.ac-steam { animation: ac-steam-kf 2.4s ease-out infinite; } +@keyframes ac-grow-w-kf { from { width: 0; } to { width: var(--w); } } +.ac-grow-w { animation: ac-grow-w-kf 1.4s ease-out forwards; } +@keyframes ac-grow-h-kf { from { height: 0; } to { height: 100px; } } +.ac-grow-h { animation: ac-grow-h-kf 1.6s ease-out forwards; } +@keyframes ac-grow-h-down-kf { from { height: 0; } to { height: 20px; } } +.ac-grow-h-down { animation: ac-grow-h-down-kf 2.4s ease-out forwards; } +@keyframes ac-spin-kf { to { transform: rotate(360deg); } } +.ac-spin { animation: ac-spin-kf 9s linear infinite; transform-box: fill-box; transform-origin: center; } +.ac-spin-slow { animation: ac-spin-kf 8s linear infinite; } +@keyframes ac-sparkle-kf { 0%, 100% { opacity: 0.2; transform: scale(0.7); } 50% { opacity: 1; transform: scale(1.25); } } +.ac-sparkle { animation: ac-sparkle-kf 1.8s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes ac-drift-kf { 0% { transform: translate(0, 0); opacity: 0.9; } 100% { transform: translate(26px, -20px); opacity: 0; } } +.ac-drift { animation: ac-drift-kf 2.6s ease-out infinite; } +@keyframes ac-shake-kf { 0%, 100% { transform: translate(0); } 25% { transform: translate(-1.5px, 1px); } 75% { transform: translate(1.5px, -1px); } } +.ac-shake { animation: ac-shake-kf 0.28s linear infinite; } +@keyframes ac-ring-kf { from { transform: scale(0.6); opacity: 0.9; } to { transform: scale(1.5); opacity: 0; } } +.ac-ring { animation: ac-ring-kf 1.7s ease-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes ac-crack-kf { from { stroke-dashoffset: 120; } to { stroke-dashoffset: 0; } } +.ac-crackline { stroke-dasharray: 120; animation: ac-crack-kf 1.4s ease-in forwards; } +@keyframes ac-dissolve-kf { 0% { transform: translate(0, 0); opacity: 1; } 100% { transform: translate(56px, -34px); opacity: 0; } } +.ac-dissolve { animation: ac-dissolve-kf 2.4s ease-out infinite; } +@keyframes ac-float-kf { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-7px); } } +.ac-float { animation: ac-float-kf 2.6s ease-in-out infinite; } +@keyframes ac-rise-kf { 0% { transform: translateY(8px); opacity: 0; } 40% { opacity: 1; } 100% { transform: translateY(-22px); opacity: 0; } } +.ac-rise { animation: ac-rise-kf 2s ease-out infinite; } +@keyframes ac-drip-kf { 0% { transform: translateY(0); opacity: 1; } 100% { transform: translateY(14px); opacity: 0; } } +.ac-drip { animation: ac-drip-kf 1.1s ease-in infinite; } +@keyframes ac-tamp-kf { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(9px); } } +.ac-tamp { animation: ac-tamp-kf 2s ease-in-out infinite; } +@keyframes ac-slide-x-kf { 0%, 100% { transform: translateX(-46px); } 50% { transform: translateX(46px); } } +.ac-slide-x { animation: ac-slide-x-kf 5s ease-in-out infinite; } +@keyframes ac-dial-kf { 0%, 100% { transform: rotate(-38deg); } 50% { transform: rotate(38deg); } } +.ac-dial { animation: ac-dial-kf 6s ease-in-out infinite; } +@keyframes ac-pour-kf { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +.ac-pourline { animation: ac-pour-kf 1.6s ease-in-out infinite; } +@keyframes ac-maillard-kf { 0% { fill: #d9ba5a; } 100% { fill: #8a5a30; } } +.ac-maillard-shift ellipse:first-child { animation: ac-maillard-kf 5s ease-in-out infinite alternate; } +@keyframes ac-tumble-kf { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-8px) rotate(6deg); } } +.ac-tumble { animation: ac-tumble-kf 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes ac-flame-kf { 0%, 100% { transform: scaleY(1); } 50% { transform: scaleY(1.12); } } +.ac-flame { animation: ac-flame-kf 0.9s ease-in-out infinite; transform-box: fill-box; transform-origin: bottom center; } +.ac-delay-0 { animation-delay: 0s; } +.ac-delay-1 { animation-delay: 0.55s; } +.ac-delay-2 { animation-delay: 1.1s; } +.ac-delay-3 { animation-delay: 1.65s; } +.ac-delay-4 { animation-delay: 2.2s; } +@media (prefers-reduced-motion: reduce) { + .academy-stage svg * { animation: none !important; } +} diff --git a/public/js/academy-content.js b/public/js/academy-content.js new file mode 100644 index 0000000..6d50d52 --- /dev/null +++ b/public/js/academy-content.js @@ -0,0 +1,438 @@ +// Academy lesson content. Each slide has: +// scene: [builderName, props] — rendered by the scene library in academy.js +// transcript: what the captions show (normal spelling) +// tts: what the narrator actually says — only present when pronunciation, numbers, or +// symbols need respelling for speech (Maillard -> "my-YARD", 8:30 -> "eight thirty", +// °C -> "degrees Celsius"). The captions never show these respellings. +// Audio files are pre-generated to /academy-audio/.mp3 by +// scripts/generate-academy-audio.mjs and committed, so the app never needs a TTS key. + +export const ACADEMY_TRACKS = [ + { + key: "roasting", + name: "Roasting", + blurb: "Follow a green bean through the fire — what happens, why, and how to steer it.", + lessons: [ + { + id: "green-bean", + title: "The green bean", + minutes: 3, + slides: [ + { + id: "gb-seed", + title: "A seed, not a bean", + scene: ["beanAnatomy", {}], + transcript: + "Coffee isn't a bean at all — it's the seed of a cherry. Under the papery silverskin sits a dense block of cellulose, packed with everything the seed saved up to grow a tree: sugars, acids, proteins, and about eleven percent water.", + }, + { + id: "gb-composition", + title: "What's inside", + scene: ["composition", {}], + transcript: + "Roughly half the seed is cell-wall cellulose — the scaffolding. The rest is the flavor pantry: sucrose, chlorogenic acids, amino acids, lipids, and trapped water. Roasting is just chemistry applied to this pantry, in a particular order.", + tts: "Roughly half the seed is cell wall cellulose — the scaffolding. The rest is the flavor pantry: sucrose, chloro-JEN-ick acids, amino acids, lipids, and trapped water. Roasting is just chemistry applied to this pantry, in a particular order.", + }, + { + id: "gb-density", + title: "Density and moisture set the plan", + scene: ["densityScale", {}], + transcript: + "A hard, high-grown bean conducts heat differently than a soft low-grown one, and every extra point of moisture is water you must boil off before browning can start. That's why the planner asks about density, moisture, and altitude — they decide how much energy the roast needs.", + }, + { + id: "gb-mission", + title: "The roaster's job", + scene: ["stageRow", { stage: 0 }], + transcript: + "The whole craft is moving this seed through three transformations — drying, browning, and development — at the right pace for this particular bean. Too fast and the outside outruns the inside. Too slow and the aromatics fade before you ever taste them.", + }, + ], + }, + { + id: "drying", + title: "Drying — the quiet third", + minutes: 4, + slides: [ + { + id: "dry-charge", + title: "Charge and the turning point", + scene: ["charge", {}], + transcript: + "Drop cold beans into a hot drum and the probe plunges — it's reading the beans, not the air. The moment the falling line bottoms out and turns is the turning point. How deep and how late it lands is your machine's thermal fingerprint: this app learns it from your logs.", + }, + { + id: "dry-endo", + title: "Boiling water uphill", + scene: ["steam", {}], + transcript: + "For the first minutes the bean mostly sweats. Water absorbs enormous energy as it evaporates, so the roast is endothermic — heat goes in, temperature climbs stubbornly. Rushing this phase steams the inside of the bean while the surface races ahead.", + tts: "For the first minutes the bean mostly sweats. Water absorbs enormous energy as it evaporates, so the roast is endo-THERM-ick — heat goes in, temperature climbs stubbornly. Rushing this phase steams the inside of the bean while the surface races ahead.", + }, + { + id: "dry-color", + title: "Green to yellow", + scene: ["stageRow", { stage: 1 }], + transcript: + "As free water leaves, chlorophyll breaks down and the bean shifts from green to pale yellow — the smell moves from grass to hay to bread. Yellow is the checkpoint: in this worksheet it should land at about fifty-six percent of the way to first crack.", + }, + { + id: "dry-share", + title: "How long is right", + scene: ["phaseBar", { highlight: "drying" }], + transcript: + "Across a healthy roast, drying takes roughly forty-three to fifty-one percent of total time. Shorter, and you risk uneven, grassy centers. Much longer, and you've spent aromatics before the browning even begins.", + }, + ], + }, + { + id: "maillard", + title: "Yellow to brown — Maillard", + minutes: 4, + slides: [ + { + id: "mai-reaction", + title: "The browning reaction", + scene: ["maillard", {}], + transcript: + "Around 150 degrees Celsius, amino acids and reducing sugars begin the Maillard reaction — the same chemistry that browns bread crusts and seared steak. Hundreds of new aroma compounds appear: malt, toast, nuts, caramel precursors.", + tts: "Around one hundred fifty degrees Celsius, amino acids and reducing sugars begin the my-YARD reaction — the same chemistry that browns bread crusts and seared steak. Hundreds of new aroma compounds appear: malt, toast, nuts, caramel precursors.", + }, + { + id: "mai-expand", + title: "The bean swells", + scene: ["expand", {}], + transcript: + "Steam and carbon dioxide build inside the sealed cellulose structure. The bean swells, its surface smooths, and the silverskin — the chaff — flakes away. Airflow matters here: chaff must leave the drum, not burn in it.", + tts: "Steam and carbon dioxide build inside the sealed cellulose structure. The bean swells, its surface smooths, and the silverskin — the chaff — flakes away. Airflow matters here: chaff must leave the drum, not burn in it.", + }, + { + id: "mai-pace", + title: "Pace is flavor", + scene: ["phaseBar", { highlight: "maillard" }], + transcript: + "Maillard time is a flavor dial. A brisk pace keeps sparkle and clarity; a longer, gentler pace builds body and sweetness at some cost of brightness. The worksheet gives this phase about a third of the roast — and your cultivar's timing anchor decides the rest.", + tts: "My-YARD time is a flavor dial. A brisk pace keeps sparkle and clarity; a longer, gentler pace builds body and sweetness at some cost of brightness. The worksheet gives this phase about a third of the roast — and your cultivar's timing anchor decides the rest.", + }, + { + id: "mai-momentum", + title: "Carry momentum, don't chase it", + scene: ["curve", { shape: "smooth", focus: "maillard" }], + transcript: + "Through this phase your rate of rise should glide gently downward. The heat you'll need at first crack must be applied a minute early — the drum answers slowly. Get ahead of the exotherm; never chase it.", + tts: "Through this phase your rate of rise should glide gently downward. The heat you'll need at first crack must be applied a minute early — the drum answers slowly. Get ahead of the exo-therm; never chase it.", + }, + ], + }, + { + id: "first-crack", + title: "First crack & development", + minutes: 5, + slides: [ + { + id: "fc-pressure", + title: "A pressure vessel fails", + scene: ["crack", { phase: "before" }], + transcript: + "By now the inside of each bean is a tiny pressure cooker — steam and carbon dioxide pushing outward at several atmospheres. Around 196 degrees Celsius the cell walls give way, and the bean audibly cracks open like popcorn.", + tts: "By now the inside of each bean is a tiny pressure cooker — steam and carbon dioxide pushing outward at several atmospheres. Around one hundred ninety six degrees Celsius the cell walls give way, and the bean audibly cracks open, like popcorn.", + }, + { + id: "fc-exotherm", + title: "The bean fights back", + scene: ["crack", { phase: "during" }], + transcript: + "First crack releases energy — the roast turns briefly exothermic and your rate of rise wants to spike, then crash. This is where most roasts are won or lost: the heat cut had to happen before the crack, and it must be gentle.", + tts: "First crack releases energy — the roast turns briefly exo-THERM-ick and your rate of rise wants to spike, then crash. This is where most roasts are won or lost: the heat cut had to happen before the crack, and it must be gentle.", + }, + { + id: "fc-dtr", + title: "Development time ratio", + scene: ["dtr", {}], + transcript: + "Everything after first crack is development. Its share of the total roast — the development time ratio — is the strongest single lever on the cup: this worksheet targets twelve to twenty percent. Under twelve reads sharp and grassy; far over twenty goes flat and roasty.", + tts: "Everything after first crack is development. Its share of the total roast — the development time ratio — is the strongest single lever on the cup: this worksheet targets twelve to twenty percent. Under twelve reads sharp and grassy; far over twenty goes flat and roasty.", + }, + { + id: "fc-chemistry", + title: "What development does", + scene: ["stageRow", { stage: 3 }], + transcript: + "In these final minutes, harsh chlorogenic acids break down, sugars caramelize, and body builds. Each cultivar has a sweet spot — a Gesha wants a fast light touch, a Bourbon rewards patience. That's exactly what the cultivar table encodes.", + tts: "In these final minutes, harsh chloro-JEN-ick acids break down, sugars caramelize, and body builds. Each cultivar has a sweet spot — a GAY-sha wants a fast light touch, a bor-BONN rewards patience. That's exactly what the cultivar table encodes.", + }, + { + id: "fc-drop", + title: "The drop, and the honest number", + scene: ["weightLoss", {}], + transcript: + "Drop the beans and cooling must be fast — carryover heat keeps roasting for another half minute. Then weigh them: weight loss rolls time, temperature, and development into one number a kitchen scale can verify. Light roasts lose eleven to thirteen percent.", + }, + ], + }, + { + id: "curve", + title: "Reading the curve", + minutes: 4, + slides: [ + { + id: "cv-anatomy", + title: "Three lines, one story", + scene: ["curve", { shape: "smooth", focus: "all" }], + transcript: + "A roast log shows bean temperature climbing through its phases, and its slope — the rate of rise. The temperature says where you are; the rate of rise says where you're going. Artisan logs both, and this app reads them from your alog files.", + tts: "A roast log shows bean temperature climbing through its phases, and its slope — the rate of rise. The temperature says where you are; the rate of rise says where you're going. Artisan logs both, and this app reads them from your A-log files.", + }, + { + id: "cv-decline", + title: "The ideal: a smooth decline", + scene: ["ror", { shape: "smooth" }], + transcript: + "After the turning point, a healthy rate of rise falls steadily and never stops falling — fast through drying, gentler through Maillard, gentle but positive through development. Every big correction you make shows up as a wave two minutes later.", + tts: "After the turning point, a healthy rate of rise falls steadily and never stops falling — fast through drying, gentler through my-YARD, gentle but positive through development. Every big correction you make shows up as a wave two minutes later.", + }, + { + id: "cv-crash", + title: "Crash and flick", + scene: ["ror", { shape: "crash" }], + transcript: + "Cut heat too late and the exotherm crashes your rate of rise — then, overcorrected, it flicks back up. A crash bakes out sweetness; a flick tastes roasty. The fix is never at the crack: it's smaller, earlier moves upstream.", + tts: "Cut heat too late and the exo-therm crashes your rate of rise — then, overcorrected, it flicks back up. A crash bakes out sweetness; a flick tastes roasty. The fix is never at the crack: it's smaller, earlier moves upstream.", + }, + { + id: "cv-stall", + title: "The stall — a flat line bakes", + scene: ["ror", { shape: "stall" }], + transcript: + "If the rate of rise flattens to zero mid-roast, the bean sits at temperature without progressing — baking. The cup goes flat, papery, and sweetless, and no amount of development rescues it. This is why the machine report watches your typical rate of rise per phase.", + }, + ], + }, + { + id: "defects", + title: "Defects → one change", + minutes: 4, + slides: [ + { + id: "df-fast", + title: "Too fast", + scene: ["symptom", { kind: "fast" }], + transcript: + "Papery, thin, starchy, hollow — the signature of reaching first crack too fast for the cultivar. The structure ruptured before the inside was ready. The single change: move first crack thirty seconds later.", + }, + { + id: "df-slow", + title: "Too slow", + scene: ["symptom", { kind: "slow" }], + transcript: + "Savory, muddled, caramel-dominant with the florals gone — you took too long. Delicate aromatics are volatile; they were spent before the drop. Move first crack thirty seconds earlier. One famous exception: Castillo and its hybrid cousins lose herbaceousness by going slower.", + tts: "Savory, muddled, caramel-dominant with the florals gone — you took too long. Delicate aromatics are volatile; they were spent before the drop. Move first crack thirty seconds earlier. One famous exception: cass-TEE-yo and its hybrid cousins lose herbaceousness by going slower.", + }, + { + id: "df-surface", + title: "Tipping and scorching", + scene: ["symptom", { kind: "surface" }], + transcript: + "Dark spots at the bean's ends mean the inlet air was too hot; flat burnt patches mean the drum surface was, or the batch was too big to keep moving. Lower the charge, raise the airflow, shrink the batch — surface defects are machine settings, not timing.", + }, + { + id: "df-onechange", + title: "One change per batch", + scene: ["oneChange", {}], + transcript: + "Move two variables and there are four explanations for what the cup does; move three and there are eight. Change exactly one thing, write it down, and let the next roast prove or disprove it. That note is what this app's refine handoff carries into your next plan.", + }, + ], + }, + ], + }, + { + key: "brewing", + name: "Brewing", + blurb: "Extraction physics first, then the technique for every brewer you own.", + lessons: [ + { + id: "extraction", + title: "Extraction — the physics of a cup", + minutes: 5, + slides: [ + { + id: "ex-dissolve", + title: "Coffee is a solution", + scene: ["dissolve", { stage: "all" }], + transcript: + "Brewing is controlled dissolving. Hot water pulls soluble material out of ground coffee in a fixed order: bright fruit acids leave first, then sweet caramels and sugars, and finally the heavy bitter compounds. Every brew method is just a different way of deciding when to stop.", + }, + { + id: "ex-yield", + title: "Strength and extraction", + scene: ["yieldMap", {}], + transcript: + "Two numbers describe any cup. Strength is how much dissolved coffee is in the water. Extraction is how much of the grounds you dissolved — sweetness peaks around eighteen to twenty-two percent. Under-extracted is sour and thin; over-extracted is bitter and drying.", + }, + { + id: "ex-grind", + title: "Grind is surface area", + scene: ["grind", {}], + transcript: + "Grinding finer doesn't make coffee stronger by magic — it exposes more surface, so water extracts faster. Finer also slows the water down through the bed. Grind size is therefore your master dial: it moves extraction and contact time at once.", + }, + { + id: "ex-temp-time", + title: "Temperature, time, ratio", + scene: ["tempTime", {}], + transcript: + "Hotter water extracts faster — 90 to 96 degrees Celsius suits most light roasts. Time decides how far along the dissolving order you travel. And the brew ratio — one part coffee to fifteen to seventeen parts water for filter — sets the canvas everything lands on.", + tts: "Hotter water extracts faster — ninety to ninety six degrees Celsius suits most light roasts. Time decides how far along the dissolving order you travel. And the brew ratio — one part coffee to fifteen to seventeen parts water for filter — sets the canvas everything lands on.", + }, + { + id: "ex-taste", + title: "Taste, then move one dial", + scene: ["tasteDial", {}], + transcript: + "Sour, sharp, salty? Extract more: grind finer, brew hotter, or longer. Bitter, drying, hollow? Extract less: coarser, cooler, shorter. Log every brew with its recipe and rating — the same one-change discipline as roasting.", + }, + ], + }, + { + id: "immersion", + title: "Immersion — steep and separate", + minutes: 5, + slides: [ + { + id: "im-principle", + title: "The principle", + scene: ["brewAnim", { method: "french-press", mode: "steep" }], + transcript: + "Immersion is the simplest physics in coffee: all the water meets all the grounds for the whole brew. Extraction is even and forgiving because nothing channels — time and temperature do all the work. The trade: less clarity, more body.", + }, + { + id: "im-frenchpress", + title: "French press", + scene: ["brewAnim", { method: "french-press", mode: "plunge" }], + transcript: + "Coarse grind, one to fifteen, four minutes. Then the trick most people miss: don't plunge hard — break the crust, skim the foam, and press just below the surface. Pouring gently keeps the fines in the pot instead of your cup.", + tts: "Coarse grind, one to fifteen, four minutes. Then the trick most people miss: don't plunge hard — break the crust, skim the foam, and press just below the surface. Pouring gently keeps the fines in the pot, instead of your cup.", + }, + { + id: "im-aeropress", + title: "AeroPress", + scene: ["brewAnim", { method: "aeropress", mode: "press" }], + transcript: + "An immersion brew finished under gentle pressure. Medium-fine grind, one to twelve, ninety seconds of steep, thirty seconds of press. The pressure pushes water through the bed at the end, so you get immersion's evenness with a cleaner, fuller finish.", + tts: "An air-o-press brew is an immersion finished under gentle pressure. Medium fine grind, one to twelve, ninety seconds of steep, thirty seconds of press. The pressure pushes water through the bed at the end, so you get immersion's evenness with a cleaner, fuller finish.", + }, + { + id: "im-switch", + title: "Clever & Hario Switch — hybrids", + scene: ["brewAnim", { method: "hario-switch", mode: "valve" }], + transcript: + "A valve under a cone gives you both worlds: steep like an immersion, then flip the switch and drain through the filter like a pour-over. Steep two minutes closed, open, and let it draw down — even extraction plus paper-filter clarity.", + tts: "A valve under a cone gives you both worlds: steep like an immersion, then flip the switch and drain through the filter like a pour over. Steep two minutes closed, open, and let it draw down — even extraction plus paper filter clarity.", + }, + { + id: "im-coldcup", + title: "Cold brew & the cupping bowl", + scene: ["brewAnim", { method: "cold-brew", mode: "steep" }], + transcript: + "Cold water extracts slowly, so cold brew trades heat for time: coarse grind, one to eight, twelve to eighteen hours, then filter. And the cupping bowl is immersion stripped bare — just grounds, water, and a spoon. It's how this app's cupping scores are meant to be tasted.", + }, + ], + }, + { + id: "percolation", + title: "Percolation — water passing through", + minutes: 5, + slides: [ + { + id: "pc-principle", + title: "The principle", + scene: ["brewAnim", { method: "v60", mode: "pour" }], + transcript: + "In percolation, fresh water continuously passes through a bed of grounds — always hungry, always extracting. That makes it efficient and brilliantly clear, but unforgiving: wherever water finds an easy path, it channels, over-extracting one spot and ignoring the rest.", + }, + { + id: "pc-bloom", + title: "The bloom", + scene: ["bloom", {}], + transcript: + "Fresh coffee is full of trapped carbon dioxide, and gas pushes water away from the grounds. So we bloom: wet the bed with twice its weight in water, watch it bubble and swell, and wait thirty to forty-five seconds. Skipping the bloom is brewing on top of a gas bubble.", + tts: "Fresh coffee is full of trapped carbon dioxide, and gas pushes water away from the grounds. So we bloom: wet the bed with twice its weight in water, watch it bubble and swell, and wait thirty to forty five seconds. Skipping the bloom is brewing on top of a gas bubble.", + }, + { + id: "pc-v60", + title: "V60 — the cone", + scene: ["brewAnim", { method: "v60", mode: "spiral" }], + transcript: + "Medium-fine grind, one to sixteen, finished around three minutes. Pour in slow spirals from the center out, keeping the bed level — the cone's single hole means your pour rate controls the flow. Gentle pulses beat one big flood.", + tts: "For the V sixty: medium fine grind, one to sixteen, finished around three minutes. Pour in slow spirals from the center out, keeping the bed level — the cone's single hole means your pour rate controls the flow. Gentle pulses beat one big flood.", + }, + { + id: "pc-flat", + title: "Chemex & flat bottoms", + scene: ["brewAnim", { method: "chemex", mode: "pour" }], + transcript: + "The Chemex's thick paper filters for the cleanest cup on this page — grind slightly coarser to keep it flowing. Flat-bottom brewers like the Kalita Wave spread water across an even bed, more forgiving of pour technique at a small cost in brightness.", + tts: "The KEM-ex's thick paper filters for the cleanest cup on this page — grind slightly coarser to keep it flowing. Flat bottom brewers like the ka-LEE-ta Wave spread water across an even bed, more forgiving of pour technique at a small cost in brightness.", + }, + { + id: "pc-batch", + title: "Auto drip & the stovetop percolator", + scene: ["brewAnim", { method: "batch", mode: "drip" }], + transcript: + "A good batch brewer is just a robot doing percolation with perfect consistency — give it the same grind and ratio you'd use on a flat bottom. The classic stovetop percolator recycles brewed coffee back over the grounds; charming, but it over-extracts by design. Keep it short.", + }, + ], + }, + { + id: "espresso", + title: "Espresso & pressure", + minutes: 5, + slides: [ + { + id: "es-principle", + title: "Nine bars through a puck", + scene: ["brewAnim", { method: "espresso", mode: "pressure" }], + transcript: + "Espresso forces water at nine atmospheres through a compressed puck of finely ground coffee — a thirty-times concentrated brew in thirty seconds. At that intensity every variable is magnified, which is why espresso rewards obsessive consistency.", + }, + { + id: "es-puck", + title: "The puck is everything", + scene: ["tamp", {}], + transcript: + "Pressurized water exploits any weakness: a crack, a loose corner, an uneven density. Dose to the tenth of a gram, distribute the grounds evenly, tamp level. Puck preparation isn't ritual — it's channel prevention.", + }, + { + id: "es-shot", + title: "Reading the shot", + scene: ["shot", {}], + transcript: + "Start from a one-to-two ratio: eighteen grams in, thirty-six out, in twenty-five to thirty-two seconds. The stream should start as slow dark drips and flow like warm honey. Blonde and gushing means under-extracted; choked drips mean grind coarser.", + tts: "Start from a one to two ratio: eighteen grams in, thirty six out, in twenty five to thirty two seconds. The stream should start as slow dark drips and flow like warm honey. Blonde and gushing means under-extracted; choked drips mean grind coarser.", + }, + { + id: "es-dial", + title: "Dialing in", + scene: ["tasteDial", {}], + transcript: + "Keep dose and ratio fixed; move only the grind. Sour and fast — finer. Bitter and slow — coarser. One change per shot, logged. When the grind is right, then and only then adjust ratio for body, or temperature for brightness.", + }, + { + id: "es-moka", + title: "The moka pot", + scene: ["brewAnim", { method: "moka", mode: "flame" }], + transcript: + "The moka pot is pressure brewing at one and a half bars: boiler steam pushes hot water up through the grounds. Fill the boiler with pre-heated water, use a medium-fine grind, never tamp, and pull it off the heat when the flow gurgles — the last steam-driven pulse scalds.", + tts: "The MOH-kah pot is pressure brewing at one and a half bars: boiler steam pushes hot water up through the grounds. Fill the boiler with pre-heated water, use a medium fine grind, never tamp, and pull it off the heat when the flow gurgles — the last steam-driven pulse scalds.", + }, + ], + }, + ], + }, +]; + +export const ACADEMY_SLIDE_COUNT = ACADEMY_TRACKS.reduce( + (sum, track) => sum + track.lessons.reduce((s, l) => s + l.slides.length, 0), + 0, +); diff --git a/public/js/academy.js b/public/js/academy.js new file mode 100644 index 0000000..13bc725 --- /dev/null +++ b/public/js/academy.js @@ -0,0 +1,660 @@ +import { protectedFetch } from "./api.js?v=__ASSET_VERSION__"; +import { initSideNav, loadNavUser } from "./nav.js?v=__ASSET_VERSION__"; +import { ACADEMY_TRACKS } from "./academy-content.js?v=__ASSET_VERSION__"; +import { BREW_SILHOUETTES, findBrewMethod } from "/shared/brew-data.js?v=__ASSET_VERSION__"; + +const NS = "http://www.w3.org/2000/svg"; + +// Shared palette — every scene draws from the same set so the whole course reads as one system. +const C = { + green: "#7a8c5e", + yellow: "#d9ba5a", + tan: "#b98d54", + brown: "#8a5a30", + dark: "#54371f", + ember: "#a8481a", + emberSoft: "#fbefe6", + ink: "#1b1614", + ink2: "#5b524b", + line: "#e4dcd2", + water: "#5f8cb0", + paper: "#fbf8f4", + pass: "#2f6f4e", + fail: "#b03a22", +}; + +function el(tag, attrs = {}, ...children) { + const node = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === "class") node.setAttribute("class", v); + else node.setAttribute(k, v); + } + node.append(...children); + return node; +} +const text = (x, y, str, size = 12, fill = C.ink2, anchor = "middle", weight = "400") => + el("text", { x, y, fill, "font-size": size, "text-anchor": anchor, "font-weight": weight, "font-family": "inherit" }, str); + +/** A coffee bean (side profile with centre cut) at x,y. */ +function bean(x, y, scale = 1, fill = C.green, opts = {}) { + const g = el("g", { transform: `translate(${x} ${y}) scale(${scale})`, class: opts.class ?? "" }); + g.append( + el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill }), + el("path", { d: "M0 -34 C 6 -16, 6 16, 0 34 C -3 16, -3 -16, 0 -34 Z", fill: "rgba(0,0,0,0.22)" }), + ); + if (opts.crack) + g.append(el("path", { d: "M-2 -30 L 5 -12 L -4 4 L 4 20 L -1 32", fill: "none", stroke: C.paper, "stroke-width": 2.4, class: "ac-crackline" })); + return g; +} + +function steamPaths(x, y, n = 3, cls = "ac-steam") { + const g = el("g"); + for (let i = 0; i < n; i++) + g.append( + el("path", { + d: `M${x + (i - 1) * 16} ${y} c -6 -12, 6 -18, 0 -30 c -5 -10, 5 -16, 0 -26`, + fill: "none", + stroke: C.ink2, + "stroke-width": 2.5, + "stroke-linecap": "round", + class: `${cls} ac-delay-${i}`, + opacity: 0.55, + }), + ); + return g; +} + +function flame(x, y, scale = 1) { + return el("g", { transform: `translate(${x} ${y}) scale(${scale})`, class: "ac-flame" }, + el("path", { d: "M0 0 C 12 -14, 4 -26, 0 -36 C -4 -26, -12 -14, 0 0 Z", fill: C.ember }), + el("path", { d: "M0 -4 C 6 -12, 2 -18, 0 -24 C -2 -18, -6 -12, 0 -4 Z", fill: C.yellow }), + ); +} + +function silhouette(methodKey, x, y, scale = 1, fill = C.ink) { + const g = el("g", { transform: `translate(${x} ${y}) scale(${scale})`, fill }); + for (const d of BREW_SILHOUETTES[methodKey] ?? []) g.append(el("path", { d })); + return g; +} + +const STAGES = [ + { name: "Green", color: C.green }, + { name: "Yellow", color: C.yellow }, + { name: "Maillard", color: C.tan }, + { name: "Development", color: C.brown }, + { name: "Dark", color: C.dark }, +]; + +// ── Scene builders ───────────────────────────────────────────────────────── +// Each returns children for a 560×300 viewBox. + +const SCENES = { + beanAnatomy() { + return [ + el("ellipse", { cx: 220, cy: 150, rx: 120, ry: 105, fill: C.green, class: "ac-breathe" }), + el("ellipse", { cx: 220, cy: 150, rx: 104, ry: 90, fill: "#8ea06f" }), + el("path", { d: "M220 55 C 250 110, 250 190, 220 245 C 200 190, 200 110, 220 55 Z", fill: "#6a7a50" }), + ...[[150, 95], [280, 110], [180, 210], [265, 195], [225, 150]].map(([x, y], i) => + el("circle", { cx: x, cy: y, r: 5, fill: C.water, class: `ac-pulse ac-delay-${i % 3}` }), + ), + el("line", { x1: 330, y1: 78, x2: 402, y2: 78, stroke: C.line, "stroke-width": 1.5 }), + text(408, 82, "silverskin (chaff)", 13, C.ink2, "start"), + el("line", { x1: 336, y1: 150, x2: 402, y2: 150, stroke: C.line, "stroke-width": 1.5 }), + text(408, 154, "endosperm — the pantry", 13, C.ink2, "start"), + el("line", { x1: 300, y1: 226, x2: 402, y2: 226, stroke: C.line, "stroke-width": 1.5 }), + text(408, 230, "≈11% water", 13, C.water, "start"), + text(220, 285, "the seed of a coffee cherry", 13, C.ink2), + ]; + }, + composition(_, slideEl) { + const parts = [ + ["Cellulose", 48, C.ink2], + ["Sugars", 12, C.tan], + ["Water", 11, C.water], + ["Lipids", 12, C.yellow], + ["Acids", 8, C.ember], + ["Proteins", 9, C.brown], + ]; + let y = 60; + const out = [bean(120, 150, 2.2, C.green)]; + for (const [name, pct, color] of parts) { + out.push( + el("rect", { x: 260, y, width: 0, height: 22, rx: 4, fill: color, class: "ac-grow-w", style: `--w:${pct * 4.6}px` }), + text(252, y + 16, name, 12.5, C.ink2, "end"), + text(268 + pct * 4.6, y + 16, `${pct}%`, 12, C.ink2, "start"), + ); + y += 34; + } + return out; + }, + densityScale() { + return [ + el("path", { d: "M110 220 q 170 44 340 0", fill: "none", stroke: C.line, "stroke-width": 3 }), + bean(140, 170, 1.15, "#93a375"), text(140, 250, "soft · low-grown", 12.5), + bean(280, 190, 1.0, C.green), text(280, 262, "typical", 12.5), + bean(420, 170, 0.85, "#5d6b45"), text(420, 250, "dense · high-grown", 12.5), + el("g", { class: "ac-slide-x" }, + el("path", { d: "M0 0 l 10 18 h -20 Z", transform: "translate(140 130)", fill: C.ember }), + ), + text(280, 60, "denser beans need more energy, delivered more patiently", 13, C.ink2), + text(280, 82, "moisture must boil off before browning can begin", 12.5, C.water), + ]; + }, + stageRow({ stage = 0 }) { + const out = []; + STAGES.forEach((s, i) => { + const x = 90 + i * 96; + out.push( + bean(x, 140, 0.95 + (i >= 3 ? 0.12 : 0), s.color, { class: i === stage ? "ac-breathe" : "", crack: i === 3 }), + text(x, 210, s.name, 12.5, i === stage ? C.ember : C.ink2, "middle", i === stage ? "700" : "400"), + ); + if (i === stage) + out.push(el("circle", { cx: x, cy: 140, r: 48, fill: "none", stroke: C.ember, "stroke-width": 2, "stroke-dasharray": "5 5", class: "ac-spin" })); + }); + out.push(el("path", { d: "M60 246 H 500", stroke: C.line, "stroke-width": 2 }), + el("path", { d: "M60 246 H 500", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }), + text(280, 275, "drying → browning → development", 12.5)); + return out; + }, + charge() { + return [ + el("rect", { x: 70, y: 70, width: 200, height: 140, rx: 70, fill: "none", stroke: C.ink2, "stroke-width": 4 }), + el("g", { class: "ac-tumble" }, + bean(140, 130, 0.55, C.green), bean(180, 150, 0.55, C.green), bean(160, 170, 0.55, "#8ea06f"), + bean(200, 120, 0.55, C.green)), + flame(170, 250, 1), + el("rect", { x: 240, y: 128, width: 46, height: 8, rx: 4, fill: C.ink2 }), + text(263, 120, "probe", 11.5), + el("path", { d: "M330 90 L 385 200 Q 397 222 412 200 L 470 110", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }), + el("circle", { cx: 399, cy: 212, r: 5, fill: C.ember, class: "ac-pulse" }), + text(399, 245, "turning point", 12.5, C.ember, "middle", "700"), + text(399, 264, "your machine's thermal fingerprint", 12), + ]; + }, + steam() { + return [ + flame(280, 292, 1.3), + bean(280, 160, 2, C.green, { class: "ac-breathe" }), + steamPaths(280, 92, 3), + text(120, 90, "energy in →", 13, C.ember, "middle", "700"), + el("path", { d: "M150 110 q 40 30 78 34", fill: "none", stroke: C.ember, "stroke-width": 2, "marker-end": "" }), + text(452, 78, "→ water out", 13, C.water, "middle", "700"), + text(280, 270, "evaporation soaks up heat — the endothermic phase", 12.5), + ]; + }, + phaseBar({ highlight }) { + const phases = [ + ["drying", 47, C.yellow, "43–51%"], + ["maillard", 36, C.tan, "33–39%"], + ["development", 17, C.brown, "12–20%"], + ]; + let x = 60; + const out = [text(280, 70, "one roast, three phases — share of total time", 13)]; + for (const [key, pct, color, band] of phases) { + const w = pct * 4.4; + const active = key === highlight; + out.push( + el("rect", { x, y: 120, width: w, height: 54, rx: 6, fill: color, opacity: active ? 1 : 0.35, class: active ? "ac-grow-w" : "", style: `--w:${w}px` }), + text(x + w / 2, 152, key === "maillard" ? "Maillard" : key[0].toUpperCase() + key.slice(1), 13, active ? C.paper : C.ink2, "middle", active ? "700" : "400"), + text(x + w / 2, 205, band, 12.5, active ? C.ember : C.ink2, "middle", active ? "700" : "400"), + ); + x += w + 4; + } + out.push(text(280, 250, "the worksheet's sanity checks are exactly these bands", 12.5)); + return out; + }, + maillard() { + return [ + bean(180, 150, 2, C.yellow, { class: "ac-maillard-shift" }), + ...[0, 1, 2, 3, 4].map((i) => + el("circle", { cx: 300 + i * 34, cy: 120 - (i % 3) * 22, r: 4, fill: i % 2 ? C.tan : C.ember, class: `ac-sparkle ac-delay-${i % 3}` }), + ), + text(390, 190, "amino acids + sugars", 13, C.ink2), + text(390, 210, "→ hundreds of aroma compounds", 13, C.ember), + text(180, 268, "≈150 °C — the browning begins", 12.5), + ]; + }, + expand() { + return [ + bean(230, 150, 2, C.tan, { class: "ac-breathe" }), + ...[0, 1, 2].map((i) => + el("path", { d: `M${300 + i * 30} ${100 + i * 26} q 14 6 22 -6`, stroke: C.yellow, "stroke-width": 3, fill: "none", class: `ac-drift ac-delay-${i}` }), + ), + text(392, 90, "chaff lifts away", 12.5, C.ink2, "start"), + text(230, 262, "steam + CO₂ swell the bean — airflow carries the chaff out", 12.5), + ]; + }, + curve({ shape = "smooth", focus = "all" }) { + const phases = [ + el("rect", { x: 60, y: 60, width: 150, height: 170, fill: C.yellow, opacity: focus === "all" || focus === "drying" ? 0.14 : 0.05 }), + el("rect", { x: 210, y: 60, width: 160, height: 170, fill: C.tan, opacity: focus === "all" || focus === "maillard" ? 0.2 : 0.05 }), + el("rect", { x: 370, y: 60, width: 130, height: 170, fill: C.brown, opacity: focus === "all" || focus === "development" ? 0.2 : 0.05 }), + ]; + return [ + ...phases, + el("path", { d: "M60 230 H 500 M60 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }), + el("path", { d: "M60 100 L 92 200 Q 100 218 112 200 C 180 105, 300 92, 500 68", fill: "none", stroke: C.ember, "stroke-width": 3, class: "ac-draw" }), + text(135, 250, "drying", 12), text(290, 250, "Maillard", 12), text(435, 250, "development", 12), + el("circle", { cx: 383, cy: 76, r: 5, fill: C.ink, class: "ac-pulse" }), + text(383, 58, "first crack", 12, C.ink, "middle", "700"), + text(280, 285, "bean temperature — the story of the roast", 12.5), + ]; + }, + crack({ phase }) { + const during = phase === "during"; + return [ + bean(230, 150, 2.1, during ? C.brown : C.tan, { crack: true, class: during ? "ac-shake" : "" }), + ...(during + ? [0, 1, 2].map((i) => + el("circle", { cx: 230, cy: 150, r: 60, fill: "none", stroke: C.ember, "stroke-width": 2, class: `ac-ring ac-delay-${i}` }), + ) + : [ + ...[0, 1, 2, 3].map((i) => + el("path", { d: `M${196 + i * 24} ${96 - (i % 2) * 10} l 6 -14`, stroke: C.ink2, "stroke-width": 2.5, class: `ac-pulse ac-delay-${i % 3}` }), + ), + ]), + text(400, 120, during ? "energy released —" : "steam + CO₂", 13, during ? C.ember : C.water, "middle", "700"), + text(400, 140, during ? "the exotherm" : "≈8 bar inside", 12.5), + text(230, 272, during ? "cut heat before this moment, gently" : "a pressure vessel about to fail", 12.5), + ]; + }, + dtr() { + return [ + el("rect", { x: 60, y: 120, width: 440, height: 40, rx: 8, fill: C.line }), + el("rect", { x: 60, y: 120, width: 360, height: 40, rx: 8, fill: C.tan, opacity: 0.5 }), + el("rect", { x: 420, y: 120, width: 0, height: 40, rx: 8, fill: C.ember, class: "ac-grow-w", style: "--w:80px" }), + el("line", { x1: 420, y1: 100, x2: 420, y2: 180, stroke: C.ink, "stroke-width": 2, "stroke-dasharray": "4 3" }), + text(420, 92, "first crack", 12, C.ink, "middle", "700"), + text(460, 145, "DTR", 13, C.paper, "middle", "700"), + text(280, 210, "development ÷ total time = 12–20%", 13.5, C.ember, "middle", "700"), + text(280, 235, "the strongest single lever on the cup", 12.5), + ]; + }, + weightLoss() { + return [ + bean(170, 130, 1.5, C.green), text(170, 205, "250 g green", 13), + el("path", { d: "M240 130 h 70", stroke: C.ink2, "stroke-width": 2, class: "ac-draw" }), + el("path", { d: "M304 124 l 10 6 l -10 6 Z", fill: C.ink2 }), + bean(380, 130, 1.62, C.brown, { crack: true }), text(380, 205, "≈220 g roasted", 13), + text(280, 250, "−11–13% for a light roast — the honest number", 13, C.ember, "middle", "700"), + text(280, 272, "time, temperature and development, measured by a kitchen scale", 12), + ]; + }, + ror({ shape }) { + const paths = { + smooth: "M60 90 C 150 130, 300 170, 500 195", + crash: "M60 90 C 150 130, 240 150, 300 160 C 340 168, 350 225, 380 228 C 405 230, 415 175, 460 185 L 500 195", + stall: "M60 90 C 140 130, 220 165, 280 178 L 420 178 C 460 178, 480 188, 500 192", + }; + const notes = { + smooth: ["a steady, always-falling decline", C.pass], + crash: ["crash… then the flick — bakes sweetness, adds roast", C.fail], + stall: ["the stall: flat means baking", C.fail], + }; + const [note, color] = notes[shape]; + return [ + el("path", { d: "M60 230 H 500 M60 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }), + text(46, 90, "RoR", 11.5, C.ink2, "end"), + el("path", { d: paths.smooth, fill: "none", stroke: C.line, "stroke-width": 2, "stroke-dasharray": "5 4" }), + el("path", { d: paths[shape], fill: "none", stroke: color, "stroke-width": 3, class: "ac-draw" }), + shape === "crash" ? el("circle", { cx: 380, cy: 228, r: 6, fill: C.fail, class: "ac-pulse" }) : el("g"), + shape === "stall" ? el("rect", { x: 280, y: 170, width: 140, height: 16, fill: C.fail, opacity: 0.12 }) : el("g"), + text(280, 268, note, 13, color, "middle", "700"), + ]; + }, + symptom({ kind }) { + if (kind === "surface") + return [ + bean(180, 140, 1.7, C.brown), + el("circle", { cx: 180, cy: 84, r: 9, fill: C.dark, class: "ac-pulse" }), + el("circle", { cx: 180, cy: 196, r: 9, fill: C.dark, class: "ac-pulse ac-delay-1" }), + text(180, 240, "tipping — ends scorched", 12.5), + bean(390, 140, 1.7, C.brown), + el("ellipse", { cx: 372, cy: 132, rx: 16, ry: 24, fill: C.dark, opacity: 0.8, class: "ac-pulse ac-delay-2" }), + text(390, 240, "scorching — drum-face burn", 12.5), + text(280, 278, "machine settings, not timing: lower charge, more airflow, smaller batch", 12), + ]; + const fast = kind === "fast"; + return [ + bean(160, 140, 1.9, fast ? "#c9a76a" : C.dark, { crack: fast }), + text(160, 240, fast ? "pale centre, papery cup" : "florals spent, savory cup", 12.5), + el("path", { d: "M300 140 h 110", stroke: C.ink2, "stroke-width": 2 }), + el("path", { d: "M404 134 l 10 6 l -10 6 Z", fill: C.ink2 }), + text(430, 120, "one change:", 12.5, C.ink2, "start"), + text(430, 146, fast ? "FC +0:30" : "FC −0:30", 17, C.ember, "start", "700"), + text(280, 278, fast ? "the crack came before the inside was ready" : "delicate aromatics are volatile — they left before the drop", 12), + ]; + }, + oneChange() { + return [ + ...[0, 1, 2].map((i) => + el("g", {}, + el("rect", { x: 90 + i * 140, y: 90, width: 110, height: 74, rx: 8, fill: i === 2 ? C.emberSoft : C.paper, stroke: i === 2 ? C.ember : C.line, "stroke-width": 1.6 }), + text(145 + i * 140, 118, `batch ${i + 1}`, 12.5, C.ink2), + text(145 + i * 140, 144, i === 0 ? "baseline" : i === 1 ? "FC +0:30" : "dev +0:15", 13, i === 2 ? C.ember : C.ink, "middle", "700"), + ), + ), + el("path", { d: "M200 127 h 26 M340 127 h 26", stroke: C.ember, "stroke-width": 2.5, class: "ac-draw" }), + text(280, 210, "one variable per batch — the only experiment you can afford", 13, C.ink, "middle", "700"), + text(280, 234, "the app carries your note into the next plan's ± Refine", 12.5), + ]; + }, + dissolve() { + const order = [ + ["acids — bright, sour", C.yellow, 0], + ["sugars — sweet", C.tan, 1], + ["bitters — heavy, dry", C.dark, 2], + ]; + return [ + el("circle", { cx: 150, cy: 150, r: 52, fill: C.brown }), + ...[...Array(9)].map((_, i) => + el("circle", { cx: 150 + Math.cos((i / 9) * 6.28) * 30, cy: 150 + Math.sin((i / 9) * 6.28) * 30, r: 5, fill: [C.yellow, C.tan, C.dark][i % 3], class: `ac-dissolve ac-delay-${i % 3}` }), + ), + ...order.map(([label, color, i]) => + el("g", {}, + el("circle", { cx: 300, cy: 100 + i * 46, r: 8, fill: color }), + text(320, 105 + i * 46, `${i + 1}. ${label}`, 13, C.ink2, "start"), + ), + ), + text(280, 262, "hot water dissolves them in this order — brewing is choosing when to stop", 12.5), + ]; + }, + yieldMap() { + return [ + el("rect", { x: 90, y: 70, width: 380, height: 150, rx: 8, fill: C.paper, stroke: C.line }), + el("rect", { x: 218, y: 70, width: 124, height: 150, fill: C.pass, opacity: 0.14 }), + text(280, 60, "extraction yield →", 12.5), + text(150, 150, "sour\nthin", 13, C.fail), + text(280, 140, "sweet spot", 13.5, C.pass, "middle", "700"), + text(280, 160, "18–22%", 13, C.pass, "middle", "700"), + text(415, 150, "bitter\ndrying", 13, C.fail), + el("circle", { cx: 280, cy: 190, r: 6, fill: C.ember, class: "ac-slide-x" }), + text(280, 250, "strength = how much coffee is in the water · extraction = how much you took from the grounds", 11.5), + ]; + }, + grind() { + return [ + ...[["coarse", 26, 3, 130], ["medium", 15, 8, 280], ["fine", 8, 18, 430]].map(([name, r, n, x]) => { + const g = el("g", {}); + for (let i = 0; i < n; i++) + g.append(el("circle", { cx: x + Math.cos((i / n) * 6.28) * (n > 4 ? 34 : 14) * (n > 10 ? 1.2 : 1), cy: 130 + Math.sin((i / n) * 6.28) * (n > 4 ? 30 : 12), r, fill: C.brown })); + g.append(text(x, 220, name, 13, C.ink2), text(x, 240, name === "fine" ? "huge surface, slow flow" : name === "coarse" ? "small surface, fast flow" : "the middle path", 11.5)); + return g; + }), + text(280, 66, "same mass of coffee — very different surface area", 13), + text(280, 278, "grind is the master dial: it moves extraction and contact time together", 12.5), + ]; + }, + tempTime() { + return [ + el("g", {}, + el("rect", { x: 100, y: 84, width: 18, height: 120, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }), + el("circle", { cx: 109, cy: 214, r: 15, fill: C.ember }), + el("rect", { x: 103, y: 110, width: 12, height: 100, rx: 6, fill: C.ember, class: "ac-grow-h" }), + text(109, 250, "90–96 °C", 12.5)), + el("g", {}, + el("circle", { cx: 280, cy: 145, r: 55, fill: "none", stroke: C.ink2, "stroke-width": 3 }), + el("line", { x1: 280, y1: 145, x2: 280, y2: 104, stroke: C.ember, "stroke-width": 3, class: "ac-spin-slow", "transform-origin": "280px 145px" }), + text(280, 250, "time = how far down the list", 12.5)), + el("g", {}, + bean(420, 118, 0.7, C.brown), text(448, 122, "1 :", 15, C.ink, "start", "700"), + ...[0, 1, 2].map((i) => el("circle", { cx: 478 + i * 16, cy: 118, r: 6, fill: C.water, class: `ac-pulse ac-delay-${i}` })), + text(452, 250, "ratio 1:15–1:17", 12.5, C.ink2)), + ]; + }, + tasteDial() { + return [ + el("path", { d: "M120 200 A 160 160 0 0 1 440 200", fill: "none", stroke: C.line, "stroke-width": 16, "stroke-linecap": "round" }), + el("path", { d: "M120 200 A 160 160 0 0 1 245 62", fill: "none", stroke: C.fail, "stroke-width": 16, "stroke-linecap": "round", opacity: 0.55 }), + el("path", { d: "M320 64 A 160 160 0 0 1 440 200", fill: "none", stroke: C.fail, "stroke-width": 16, "stroke-linecap": "round", opacity: 0.55 }), + text(120, 240, "sour · sharp", 13, C.fail), + text(280, 52, "sweet & balanced", 13, C.pass, "middle", "700"), + text(440, 240, "bitter · drying", 13, C.fail), + el("line", { x1: 280, y1: 200, x2: 280, y2: 90, stroke: C.ink, "stroke-width": 4, class: "ac-dial", "transform-origin": "280px 200px" }), + el("circle", { cx: 280, cy: 200, r: 10, fill: C.ink }), + text(280, 268, "extract more ← grind finer · hotter · longer → extract less", 12.5), + ]; + }, + bloom() { + return [ + silhouette("v60", 210, 60, 2.2, C.ink2), + ...[0, 1, 2, 3].map((i) => + el("circle", { cx: 250 + (i % 2) * 30, cy: 118 - i * 4, r: 5 - i, fill: C.tan, class: `ac-rise ac-delay-${i}` }), + ), + text(430, 110, "CO₂ escaping", 12.5, C.tan, "start"), + text(430, 132, "the bed swells & bubbles", 12.5, C.ink2, "start"), + text(280, 262, "2× the coffee's weight in water · wait 30–45 s", 13, C.ember, "middle", "700"), + ]; + }, + tamp() { + return [ + el("rect", { x: 200, y: 100, width: 160, height: 60, rx: 8, fill: C.ink2 }), + el("rect", { x: 210, y: 110, width: 140, height: 42, rx: 4, fill: C.brown }), + el("rect", { x: 240, y: 40, width: 80, height: 40, rx: 6, fill: C.ink, class: "ac-tamp" }), + el("rect", { x: 270, y: 20, width: 20, height: 26, rx: 6, fill: C.ink }), + ...[0, 1, 2].map((i) => el("path", { d: `M${232 + i * 40} 170 v 16`, stroke: C.ink2, "stroke-width": 2.5, class: `ac-pulse ac-delay-${i}` })), + text(280, 220, "dose to 0.1 g · distribute · tamp level", 13, C.ink, "middle", "700"), + text(280, 244, "pressurized water exploits any weakness — puck prep is channel prevention", 12), + ]; + }, + shot() { + return [ + el("rect", { x: 230, y: 60, width: 100, height: 34, rx: 6, fill: C.ink }), + el("path", { d: "M270 94 v 30 M290 94 v 30", stroke: C.brown, "stroke-width": 5, class: "ac-drip" }), + el("path", { d: "M252 140 h 56 l -6 34 h -44 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }), + el("rect", { x: 250, y: 152, width: 60, height: 0, fill: C.tan, class: "ac-grow-h-down" }), + text(400, 110, "18 g in → 36 g out", 13.5, C.ink, "start", "700"), + text(400, 134, "25–32 seconds", 13, C.ink2, "start"), + text(400, 158, "flows like warm honey", 12.5, C.tan, "start"), + text(280, 240, "blonde & gushing → finer · choked drips → coarser", 12.5), + ]; + }, + brewAnim({ method, mode }) { + const m = findBrewMethod(method); + const out = [ + silhouette(method, 190, 40, 2.8, C.ink2), + text(280, 280, m?.name ?? method, 13.5, C.ink, "middle", "700"), + ]; + if (mode === "steep" || mode === "valve") + out.push( + ...[...Array(6)].map((_, i) => + el("circle", { cx: 250 + (i % 3) * 26, cy: 120 + Math.floor(i / 3) * 22, r: 4.5, fill: C.brown, class: `ac-float ac-delay-${i % 3}` }), + ), + text(430, 120, mode === "valve" ? "steep… then open the valve" : "all the water, all the grounds", 12.5, C.ink2, "start"), + ); + if (mode === "plunge" || mode === "press") + out.push( + el("path", { d: "M275 70 v 50", stroke: C.ember, "stroke-width": 5, class: "ac-tamp" }), + text(430, 120, mode === "press" ? "steep 1:30, press 0:30" : "press gently — leave the fines behind", 12.5, C.ink2, "start"), + ); + if (mode === "pour" || mode === "spiral" || mode === "drip") + out.push( + el("path", { d: mode === "spiral" ? "M262 58 q 18 -14 36 0 q -18 12 -36 0" : "M268 46 q 12 -10 24 0", fill: "none", stroke: C.water, "stroke-width": 3, class: "ac-pourline" }), + ...[0, 1, 2].map((i) => el("line", { x1: 272 + i * 8, y1: 60, x2: 272 + i * 8, y2: 78, stroke: C.water, "stroke-width": 2.5, class: `ac-drip ac-delay-${i}` })), + text(430, 110, mode === "spiral" ? "slow spirals, centre out" : mode === "drip" ? "the machine does the pouring" : "fresh water, always passing through", 12.5, C.ink2, "start"), + ); + if (mode === "pressure") + out.push( + el("path", { d: "M150 90 h 30", stroke: C.ember, "stroke-width": 5, class: "ac-pulse" }), + text(140, 70, "9 bar", 13.5, C.ember, "middle", "700"), + ); + if (mode === "flame") out.push(flame(280, 292, 1.1), text(430, 150, "≈1.5 bar of boiler steam", 12.5, C.ink2, "start")); + return out; + }, +}; + +// ── Player ───────────────────────────────────────────────────────────────── + +let current = null; // {track, lesson, index} +const audio = new Audio(); +let autoplay = true; + +const $ = (id) => document.getElementById(id); + +function renderMenu() { + const mount = $("academy-menu"); + mount.replaceChildren( + ...ACADEMY_TRACKS.map((track) => { + const wrap = document.createElement("section"); + wrap.className = "panel-card"; + const head = document.createElement("div"); + head.className = "panel-head"; + const h2 = document.createElement("h2"); + h2.textContent = track.name; + head.append(h2); + const body = document.createElement("div"); + body.className = "panel-body"; + const blurb = document.createElement("p"); + blurb.className = "muted"; + blurb.style.marginTop = "0"; + blurb.textContent = track.blurb; + body.append(blurb); + const list = document.createElement("div"); + list.className = "academy-lessons"; + track.lessons.forEach((lesson, li) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "academy-lesson"; + const num = document.createElement("span"); + num.className = "academy-lesson-num"; + num.textContent = String(li + 1); + const label = document.createElement("span"); + label.className = "academy-lesson-label"; + label.textContent = lesson.title; + const meta = document.createElement("span"); + meta.className = "academy-lesson-meta"; + meta.textContent = `${lesson.slides.length} slides · ~${lesson.minutes} min`; + btn.append(num, label, meta); + btn.addEventListener("click", () => openLesson(track, lesson, 0)); + list.append(btn); + }); + body.append(list); + wrap.append(head, body); + return wrap; + }), + ); +} + +function openLesson(track, lesson, index) { + current = { track, lesson, index }; + $("academy-menu").classList.add("hidden"); + $("academy-player").classList.remove("hidden"); + renderSlide(); +} + +function closeLesson() { + audio.pause(); + current = null; + $("academy-player").classList.add("hidden"); + $("academy-menu").classList.remove("hidden"); +} + +function renderSlide() { + const { lesson, index, track } = current; + const slide = lesson.slides[index]; + $("player-lesson").textContent = `${track.name} — ${lesson.title}`; + $("player-slide-title").textContent = slide.title; + $("player-count").textContent = `${index + 1} / ${lesson.slides.length}`; + + // Scene: rebuild so its CSS animations restart with the slide. + const svg = $("player-scene"); + svg.replaceChildren(); + try { + svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}, svg)); + } catch { + svg.append(text(280, 150, slide.title, 16, C.ink2)); + } + + $("player-caption").textContent = slide.transcript; + + const dots = $("player-dots"); + dots.replaceChildren( + ...lesson.slides.map((s, i) => { + const dot = document.createElement("button"); + dot.type = "button"; + dot.className = `academy-dot${i === index ? " active" : ""}`; + dot.setAttribute("aria-label", `Slide ${i + 1}: ${s.title}`); + dot.addEventListener("click", () => { + current.index = i; + renderSlide(); + }); + return dot; + }), + ); + $("player-prev").disabled = index === 0; + $("player-next").textContent = + index === lesson.slides.length - 1 ? "Finish lesson" : "Next →"; + + audio.src = `/academy-audio/${slide.id}.mp3?v=__ASSET_VERSION__`; + if (autoplay) audio.play().catch(() => setPlayState(false)); + setPlayState(autoplay); +} + +function setPlayState(playing) { + $("player-play").textContent = playing ? "❚❚" : "▶"; + $("player-play").setAttribute("aria-label", playing ? "Pause narration" : "Play narration"); +} + +function next() { + const { lesson, index } = current; + if (index >= lesson.slides.length - 1) { + closeLesson(); + return; + } + current.index++; + renderSlide(); +} + +function wirePlayer() { + $("player-close").addEventListener("click", closeLesson); + $("player-prev").addEventListener("click", () => { + if (current.index > 0) { + current.index--; + renderSlide(); + } + }); + $("player-next").addEventListener("click", next); + $("player-play").addEventListener("click", () => { + if (audio.paused) { + autoplay = true; + audio.play().catch(() => {}); + setPlayState(true); + } else { + audio.pause(); + setPlayState(false); + } + }); + audio.addEventListener("ended", () => { + setPlayState(false); + // Give the animation a beat, then advance — hands-free lesson flow. + if (autoplay && current) setTimeout(() => current && next(), 1200); + }); + document.addEventListener("keydown", (event) => { + if (!current) return; + if (event.key === "ArrowRight") next(); + if (event.key === "ArrowLeft" && current.index > 0) { + current.index--; + renderSlide(); + } + if (event.key === "Escape") closeLesson(); + if (event.key === " " && event.target === document.body) { + event.preventDefault(); + $("player-play").click(); + } + }); +} + +document.getElementById("btn-logout").addEventListener("click", async () => { + await protectedFetch("/api/auth/logout", { method: "POST" }); + location.assign("/"); +}); + +async function init() { + initSideNav(); + const user = await loadNavUser(); + if (!user) return; + renderMenu(); + wirePlayer(); +} + +init(); diff --git a/public/js/nav.js b/public/js/nav.js index f1db881..c3e2e08 100644 --- a/public/js/nav.js +++ b/public/js/nav.js @@ -23,6 +23,10 @@ const NAV_GROUPS = [ { href: "/gear", icon: "⚒", label: "Equipment" }, ], }, + { + title: "Learn", + items: [{ href: "/academy", icon: "✦", label: "Academy" }], + }, { title: null, items: [ diff --git a/public/js/roasts.js b/public/js/roasts.js index ffead66..d126d52 100644 --- a/public/js/roasts.js +++ b/public/js/roasts.js @@ -457,6 +457,35 @@ document.getElementById("after-form").addEventListener("submit", async (event) = } }); +// Open (or create) the cupping session for this roast's plan — same flow the planner offers. +document.getElementById("open-cupping").addEventListener("click", async (event) => { + const button = event.currentTarget; + if (!openId || button.disabled) return; + const roast = roasts.find((r) => r.id === openId); + const planId = roast?.roastPlanId; + if (!planId) { + showToast("Attach a plan to this roast first — cupping sessions hang off plans.", "fail"); + return; + } + button.disabled = true; + try { + const existing = await api(`/api/cupping?plan=${encodeURIComponent(planId)}`).catch(() => ({ sessions: [] })); + if (existing.sessions?.length) { + location.assign(`/cupping?session=${existing.sessions[0].id}`); + return; + } + const created = await api("/api/cupping", { + method: "POST", + body: JSON.stringify({ roastPlanId: planId }), + }); + location.assign(`/cupping?session=${created.session.id}`); + } catch (error) { + showToast(error.message, "fail"); + } finally { + button.disabled = false; + } +}); + function closeDetail() { openId = null; history.replaceState(null, "", "/roasts"); diff --git a/public/roasts.html b/public/roasts.html index 89518af..4c1624f 100644 --- a/public/roasts.html +++ b/public/roasts.html @@ -199,6 +199,16 @@ >Cup notes +

+ + Score it properly on the cupping sheet — the session + links through this roast's plan and lands in the updated + .alog. +