Academy v2: one trackable roasting lesson; every scene redesigned to layout discipline
Test and deploy / test-and-deploy (push) Successful in 1m2s
Test and deploy / test-and-deploy (push) Successful in 1m2s
- Roasting is now a single continuous 11-slide lesson ('The roast,
start to finish') merging the six lessons' content; brewing keeps its
four per-family lessons
- Progress tracking: resume-where-you-left-off per lesson, progress bar
and completed badge on the lesson cards (localStorage)
- Every scene rebuilt against a fixed safe area (nothing off-canvas),
with animations that depict the narrated mechanism: contained
thermometer fills, a drawn dip-and-turn for the turning point, chaff
drifting off the swelling Maillard bean, crash/flick/stall on one
annotated RoR chart, per-brewer water/grounds anchored to each
silhouette's actual bowl, gauge needle sweeps instead of spinning
clocks; multiline labels replace out-of-frame text
- 9 new narration clips generated, 23 orphaned ones removed (31 total)
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
64c1b3605b
commit
9448712a8a
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -3236,3 +3236,37 @@ select.f {
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.academy-stage svg * { animation: none !important; }
|
.academy-stage svg * { animation: none !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Academy v2: progress + refined scene animations */
|
||||||
|
.academy-lesson-mid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.academy-lesson-num.done {
|
||||||
|
background: var(--pass-bg);
|
||||||
|
color: var(--pass);
|
||||||
|
}
|
||||||
|
.academy-progress {
|
||||||
|
display: block;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--surface-sunken);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.academy-progress-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ember);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
@keyframes ac-fill-up-kf { from { transform: scaleY(0.12); } to { transform: scaleY(0.86); } }
|
||||||
|
.ac-fill-up { transform-box: fill-box; transform-origin: bottom; animation: ac-fill-up-kf 5s ease-out forwards; }
|
||||||
|
@keyframes ac-dry-shift-kf { 0% { fill: #7a8c5e; } 100% { fill: #d9ba5a; } }
|
||||||
|
.ac-dry-shift > ellipse:first-child { animation: ac-dry-shift-kf 6s ease-in-out infinite alternate; }
|
||||||
|
@keyframes ac-needle-kf { 0%, 100% { transform: rotate(-52deg); } 50% { transform: rotate(52deg); } }
|
||||||
|
.ac-needle { animation: ac-needle-kf 7s ease-in-out infinite; }
|
||||||
|
@keyframes ac-pulse-soft-kf { 0%, 100% { opacity: 1; } 50% { opacity: 0.75; } }
|
||||||
|
.ac-pulse-soft { animation: ac-pulse-soft-kf 2.4s ease-in-out infinite; }
|
||||||
|
|||||||
+37
-175
@@ -6,17 +6,19 @@
|
|||||||
// °C -> "degrees Celsius"). The captions never show these respellings.
|
// °C -> "degrees Celsius"). The captions never show these respellings.
|
||||||
// Audio files are pre-generated to /academy-audio/<slide id>.mp3 by
|
// Audio files are pre-generated to /academy-audio/<slide id>.mp3 by
|
||||||
// scripts/generate-academy-audio.mjs and committed, so the app never needs a TTS key.
|
// scripts/generate-academy-audio.mjs and committed, so the app never needs a TTS key.
|
||||||
|
// Slide ids are stable — renaming one orphans its committed audio.
|
||||||
|
|
||||||
export const ACADEMY_TRACKS = [
|
export const ACADEMY_TRACKS = [
|
||||||
{
|
{
|
||||||
key: "roasting",
|
key: "roasting",
|
||||||
name: "Roasting",
|
name: "Roasting",
|
||||||
blurb: "Follow a green bean through the fire — what happens, why, and how to steer it.",
|
blurb:
|
||||||
|
"One continuous journey — a green seed to a finished roast, with the physics at every step.",
|
||||||
lessons: [
|
lessons: [
|
||||||
{
|
{
|
||||||
id: "green-bean",
|
id: "roast-journey",
|
||||||
title: "The green bean",
|
title: "The roast, start to finish",
|
||||||
minutes: 3,
|
minutes: 9,
|
||||||
slides: [
|
slides: [
|
||||||
{
|
{
|
||||||
id: "gb-seed",
|
id: "gb-seed",
|
||||||
@@ -34,213 +36,73 @@ export const ACADEMY_TRACKS = [
|
|||||||
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.",
|
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",
|
id: "rj-charge",
|
||||||
title: "Density and moisture set the plan",
|
title: "Charge, and the turning point",
|
||||||
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", {}],
|
scene: ["charge", {}],
|
||||||
transcript:
|
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.",
|
"Drop cold beans into a hot drum and the probe plunges — it's reading the beans, not the air. Where 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",
|
id: "rj-drying",
|
||||||
title: "Boiling water uphill",
|
title: "Drying — boiling water uphill",
|
||||||
scene: ["steam", {}],
|
scene: ["steam", {}],
|
||||||
transcript:
|
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.",
|
"For the first minutes the bean mostly sweats. Evaporating water soaks up enormous energy, so temperature climbs stubbornly while the bean fades from green to yellow — grass, then hay, then bread. Drying earns forty-three to fifty-one percent of the whole roast. Rush it, and the surface races ahead of a steamy centre.",
|
||||||
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.",
|
tts: "For the first minutes the bean mostly sweats. Evaporating water soaks up enormous energy, so temperature climbs stubbornly while the bean fades from green to yellow — grass, then hay, then bread. Drying earns forty three to fifty one percent of the whole roast. Rush it, and the surface races ahead of a steamy centre.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "dry-color",
|
id: "rj-maillard",
|
||||||
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",
|
title: "Yellow to brown — Maillard",
|
||||||
minutes: 4,
|
|
||||||
slides: [
|
|
||||||
{
|
|
||||||
id: "mai-reaction",
|
|
||||||
title: "The browning reaction",
|
|
||||||
scene: ["maillard", {}],
|
scene: ["maillard", {}],
|
||||||
transcript:
|
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.",
|
"Around 150 degrees Celsius, amino acids and sugars begin the Maillard reaction — the chemistry that browns bread and seared steak. Hundreds of aroma compounds appear, steam and carbon dioxide swell the bean, and the silverskin flakes off as chaff. Brisk keeps sparkle; slow builds body and sweetness.",
|
||||||
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.",
|
tts: "Around one hundred fifty degrees Celsius, amino acids and sugars begin the my-YARD reaction — the chemistry that browns bread and seared steak. Hundreds of aroma compounds appear, steam and carbon dioxide swell the bean, and the silverskin flakes off as chaff. Brisk keeps sparkle; slow builds body and sweetness.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "mai-expand",
|
id: "rj-crack",
|
||||||
title: "The bean swells",
|
title: "First crack",
|
||||||
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" }],
|
scene: ["crack", { phase: "during" }],
|
||||||
transcript:
|
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.",
|
"By now each bean is a tiny pressure cooker, several atmospheres of steam pushing outward. Around 196 degrees Celsius the cell walls fail and the bean audibly pops. The crack releases heat — so the gentle heat cut had to happen a minute before, not after. Get ahead of the exotherm; never chase it.",
|
||||||
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.",
|
tts: "By now each bean is a tiny pressure cooker, several atmospheres of steam pushing outward. Around one hundred ninety six degrees Celsius the cell walls fail and the bean audibly pops. The crack releases heat — so the gentle heat cut had to happen a minute before, not after. Get ahead of the exo-therm; never chase it.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fc-dtr",
|
id: "rj-development",
|
||||||
title: "Development time ratio",
|
title: "Development — the strongest lever",
|
||||||
scene: ["dtr", {}],
|
scene: ["dtr", {}],
|
||||||
transcript:
|
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.",
|
"Everything after first crack is development: harsh chlorogenic acids break down, sugars caramelize, body builds. Its share of the roast — the development time ratio — is the strongest single lever on the cup. This worksheet targets twelve to twenty percent: under it reads sharp and grassy, far over it 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.",
|
tts: "Everything after first crack is development: harsh chloro-JEN-ick acids break down, sugars caramelize, body builds. Its share of the roast — the development time ratio — is the strongest single lever on the cup. This worksheet targets twelve to twenty percent: under it reads sharp and grassy, far over it goes flat and roasty.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "fc-chemistry",
|
id: "rj-drop",
|
||||||
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",
|
title: "The drop, and the honest number",
|
||||||
scene: ["weightLoss", {}],
|
scene: ["weightLoss", {}],
|
||||||
transcript:
|
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.",
|
"Drop and cool fast — carryover heat keeps roasting for another half minute. Then weigh: weight loss rolls time, temperature, and development into one number a kitchen scale can verify. A light roast loses eleven to thirteen percent.",
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "curve",
|
id: "rj-curve",
|
||||||
title: "Reading the curve",
|
title: "Reading the curve",
|
||||||
minutes: 4,
|
|
||||||
slides: [
|
|
||||||
{
|
|
||||||
id: "cv-anatomy",
|
|
||||||
title: "Three lines, one story",
|
|
||||||
scene: ["curve", { shape: "smooth", focus: "all" }],
|
scene: ["curve", { shape: "smooth", focus: "all" }],
|
||||||
transcript:
|
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.",
|
"The whole story fits one chart: temperature dips to the turning point, climbs through drying and Maillard, cracks, and lands at the drop. Its slope — the rate of rise — should fall steadily and never stop falling. The temperature says where you are; the slope says where you're headed.",
|
||||||
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.",
|
tts: "The whole story fits one chart: temperature dips to the turning point, climbs through drying and my-YARD, cracks, and lands at the drop. Its slope — the rate of rise — should fall steadily and never stop falling. The temperature says where you are; the slope says where you're headed.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cv-decline",
|
id: "rj-ror",
|
||||||
title: "The ideal: a smooth decline",
|
title: "Crash, flick, and stall",
|
||||||
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" }],
|
scene: ["ror", { shape: "crash" }],
|
||||||
transcript:
|
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.",
|
"Cut heat too late and the exotherm crashes the rate of rise, then it flicks back up — a crash bakes out sweetness, a flick tastes roasty. Flatten to zero and the bean just sits there, baking. All three are fixed the same way: smaller, earlier moves, upstream of the crack.",
|
||||||
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.",
|
tts: "Cut heat too late and the exo-therm crashes the rate of rise, then it flicks back up — a crash bakes out sweetness, a flick tastes roasty. Flatten to zero and the bean just sits there, baking. All three are fixed the same way: smaller, earlier moves, upstream of the crack.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "cv-stall",
|
id: "rj-onechange",
|
||||||
title: "The stall — a flat line bakes",
|
title: "Taste, then change one thing",
|
||||||
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", {}],
|
scene: ["oneChange", {}],
|
||||||
transcript:
|
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.",
|
"Papery and thin? First crack came too fast — add thirty seconds. Savory with the florals gone? Too slow — take thirty off. Sharp acidity? Development plus fifteen. Change exactly one variable per batch, write it down, and let the next roast prove it. That note is what this app carries into your next plan.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -259,7 +121,7 @@ export const ACADEMY_TRACKS = [
|
|||||||
{
|
{
|
||||||
id: "ex-dissolve",
|
id: "ex-dissolve",
|
||||||
title: "Coffee is a solution",
|
title: "Coffee is a solution",
|
||||||
scene: ["dissolve", { stage: "all" }],
|
scene: ["dissolve", {}],
|
||||||
transcript:
|
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.",
|
"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.",
|
||||||
},
|
},
|
||||||
|
|||||||
+382
-318
@@ -5,9 +5,14 @@ import { BREW_SILHOUETTES, findBrewMethod } from "/shared/brew-data.js?v=__ASSET
|
|||||||
|
|
||||||
const NS = "http://www.w3.org/2000/svg";
|
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.
|
// ── 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 = {
|
const C = {
|
||||||
green: "#7a8c5e",
|
green: "#7a8c5e",
|
||||||
|
greenDark: "#5d6b45",
|
||||||
yellow: "#d9ba5a",
|
yellow: "#d9ba5a",
|
||||||
tan: "#b98d54",
|
tan: "#b98d54",
|
||||||
brown: "#8a5a30",
|
brown: "#8a5a30",
|
||||||
@@ -25,40 +30,49 @@ const C = {
|
|||||||
|
|
||||||
function el(tag, attrs = {}, ...children) {
|
function el(tag, attrs = {}, ...children) {
|
||||||
const node = document.createElementNS(NS, tag);
|
const node = document.createElementNS(NS, tag);
|
||||||
for (const [k, v] of Object.entries(attrs)) {
|
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
||||||
if (k === "class") node.setAttribute("class", v);
|
|
||||||
else node.setAttribute(k, v);
|
|
||||||
}
|
|
||||||
node.append(...children);
|
node.append(...children);
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
const text = (x, y, str, size = 12, fill = C.ink2, anchor = "middle", weight = "400") =>
|
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, "font-family": "inherit" }, str);
|
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);
|
||||||
|
|
||||||
/** A coffee bean (side profile with centre cut) at x,y. */
|
/** Coffee bean, side profile with centre cut, centred on (x,y). Height ≈ 72·scale. */
|
||||||
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
function bean(x, y, scale = 1, fill = C.green, opts = {}) {
|
||||||
const g = el("g", { transform: `translate(${x} ${y}) scale(${scale})`, class: opts.class ?? "" });
|
const g = el("g", {
|
||||||
|
transform: `translate(${x} ${y}) scale(${scale})`,
|
||||||
|
class: opts.class ?? "",
|
||||||
|
});
|
||||||
g.append(
|
g.append(
|
||||||
el("ellipse", { cx: 0, cy: 0, rx: 26, ry: 36, fill }),
|
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)" }),
|
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)
|
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" }));
|
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;
|
return g;
|
||||||
}
|
}
|
||||||
|
|
||||||
function steamPaths(x, y, n = 3, cls = "ac-steam") {
|
function steamWisps(x, y, n = 3) {
|
||||||
const g = el("g");
|
const g = el("g");
|
||||||
for (let i = 0; i < n; i++)
|
for (let i = 0; i < n; i++)
|
||||||
g.append(
|
g.append(
|
||||||
el("path", {
|
el("path", {
|
||||||
d: `M${x + (i - 1) * 16} ${y} c -6 -12, 6 -18, 0 -30 c -5 -10, 5 -16, 0 -26`,
|
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",
|
fill: "none",
|
||||||
stroke: C.ink2,
|
stroke: C.ink2,
|
||||||
"stroke-width": 2.5,
|
"stroke-width": 2.4,
|
||||||
"stroke-linecap": "round",
|
"stroke-linecap": "round",
|
||||||
class: `${cls} ac-delay-${i}`,
|
class: `ac-steam ac-delay-${i}`,
|
||||||
opacity: 0.55,
|
opacity: 0.5,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return g;
|
return g;
|
||||||
@@ -66,423 +80,451 @@ function steamPaths(x, y, n = 3, cls = "ac-steam") {
|
|||||||
|
|
||||||
function flame(x, y, scale = 1) {
|
function flame(x, y, scale = 1) {
|
||||||
return el("g", { transform: `translate(${x} ${y}) scale(${scale})`, class: "ac-flame" },
|
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 0 C 11 -12, 4 -22, 0 -32 C -4 -22, -11 -12, 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 }),
|
el("path", { d: "M0 -3 C 5 -10, 2 -15, 0 -21 C -2 -15, -5 -10, 0 -3 Z", fill: C.yellow }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function silhouette(methodKey, x, y, scale = 1, fill = C.ink) {
|
const SIL_SCALE = 2.6;
|
||||||
const g = el("g", { transform: `translate(${x} ${y}) scale(${scale})`, fill });
|
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 }));
|
for (const d of BREW_SILHOUETTES[methodKey] ?? []) g.append(el("path", { d }));
|
||||||
return g;
|
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],
|
||||||
|
};
|
||||||
|
|
||||||
const STAGES = [
|
// ── Scene builders (each returns children for the 560×300 canvas) ─────────
|
||||||
{ 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 = {
|
const SCENES = {
|
||||||
beanAnatomy() {
|
beanAnatomy() {
|
||||||
|
const cx = 180, cy = 145;
|
||||||
return [
|
return [
|
||||||
el("ellipse", { cx: 220, cy: 150, rx: 120, ry: 105, fill: C.green, class: "ac-breathe" }),
|
el("ellipse", { cx, cy, rx: 108, ry: 95, fill: C.green, class: "ac-breathe" }),
|
||||||
el("ellipse", { cx: 220, cy: 150, rx: 104, ry: 90, fill: "#8ea06f" }),
|
el("ellipse", { cx, cy, rx: 92, ry: 80, fill: "#8ea06f" }),
|
||||||
el("path", { d: "M220 55 C 250 110, 250 190, 220 245 C 200 190, 200 110, 220 55 Z", fill: "#6a7a50" }),
|
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" }),
|
||||||
...[[150, 95], [280, 110], [180, 210], [265, 195], [225, 150]].map(([x, y], i) =>
|
...[[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-${i % 3}` }),
|
el("circle", { cx: x, cy: y, r: 5, fill: C.water, class: `ac-pulse ac-delay-${d}` }),
|
||||||
),
|
),
|
||||||
el("line", { x1: 330, y1: 78, x2: 402, y2: 78, stroke: C.line, "stroke-width": 1.5 }),
|
// Legend, leader lines rooted on the feature they name
|
||||||
text(408, 82, "silverskin (chaff)", 13, C.ink2, "start"),
|
el("line", { x1: cx + 96, y1: cy - 62, x2: 366, y2: 70, stroke: C.line, "stroke-width": 1.5 }),
|
||||||
el("line", { x1: 336, y1: 150, x2: 402, y2: 150, stroke: C.line, "stroke-width": 1.5 }),
|
text(374, 74, "silverskin — the chaff", 12.5, C.ink2, "start"),
|
||||||
text(408, 154, "endosperm — the pantry", 13, C.ink2, "start"),
|
el("line", { x1: cx + 88, y1: cy, x2: 366, y2: 140, stroke: C.line, "stroke-width": 1.5 }),
|
||||||
el("line", { x1: 300, y1: 226, x2: 402, y2: 226, stroke: C.line, "stroke-width": 1.5 }),
|
text(374, 136, "endosperm — the pantry:", 12.5, C.ink2, "start"),
|
||||||
text(408, 230, "≈11% water", 13, C.water, "start"),
|
text(374, 154, "sugars, acids, proteins", 12.5, C.ink2, "start"),
|
||||||
text(220, 285, "the seed of a coffee cherry", 13, C.ink2),
|
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(_, slideEl) {
|
composition() {
|
||||||
const parts = [
|
const parts = [
|
||||||
["Cellulose", 48, C.ink2],
|
["Cellulose", 48, C.ink2],
|
||||||
["Sugars", 12, C.tan],
|
["Sugars", 12, C.tan],
|
||||||
["Water", 11, C.water],
|
|
||||||
["Lipids", 12, C.yellow],
|
["Lipids", 12, C.yellow],
|
||||||
["Acids", 8, C.ember],
|
["Water", 11, C.water],
|
||||||
["Proteins", 9, C.brown],
|
["Proteins", 9, C.brown],
|
||||||
|
["Acids", 8, C.ember],
|
||||||
];
|
];
|
||||||
let y = 60;
|
const out = [bean(110, 140, 1.9, C.green, { class: "ac-breathe" })];
|
||||||
const out = [bean(120, 150, 2.2, C.green)];
|
parts.forEach(([name, pct, color], i) => {
|
||||||
for (const [name, pct, color] of parts) {
|
const y = 52 + i * 34;
|
||||||
|
const w = pct * 4.4;
|
||||||
out.push(
|
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(248, y + 16, name, 12.5, C.ink2, "end"),
|
||||||
text(252, y + 16, name, 12.5, C.ink2, "end"),
|
el("rect", { x: 258, y, width: 0, height: 21, rx: 4, fill: color, class: `ac-grow-w ac-delay-${Math.min(i, 4)}`, style: `--w:${w}px` }),
|
||||||
text(268 + pct * 4.6, y + 16, `${pct}%`, 12, C.ink2, "start"),
|
text(266 + w, 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 }),
|
out.push(caption("half scaffolding, half flavor pantry — plus the water"));
|
||||||
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;
|
return out;
|
||||||
},
|
},
|
||||||
charge() {
|
charge() {
|
||||||
return [
|
return [
|
||||||
el("rect", { x: 70, y: 70, width: 200, height: 140, rx: 70, fill: "none", stroke: C.ink2, "stroke-width": 4 }),
|
// 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" },
|
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(126, 118, 0.5, C.green), bean(172, 132, 0.5, C.green),
|
||||||
bean(200, 120, 0.55, C.green)),
|
bean(140, 162, 0.5, "#8ea06f"), bean(182, 168, 0.5, C.green)),
|
||||||
flame(170, 250, 1),
|
flame(150, 250, 1),
|
||||||
el("rect", { x: 240, y: 128, width: 46, height: 8, rx: 4, fill: C.ink2 }),
|
el("line", { x1: 254, y1: 120, x2: 205, y2: 132, stroke: C.ink, "stroke-width": 3, "stroke-linecap": "round" }),
|
||||||
text(263, 120, "probe", 11.5),
|
el("circle", { cx: 205, cy: 132, r: 4, fill: C.ink }),
|
||||||
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" }),
|
text(260, 116, "probe", 11.5, C.ink2, "start"),
|
||||||
el("circle", { cx: 399, cy: 212, r: 5, fill: C.ember, class: "ac-pulse" }),
|
// Temperature dip-and-turn, drawn live
|
||||||
text(399, 245, "turning point", 12.5, C.ember, "middle", "700"),
|
el("path", { d: "M310 230 H 520 M310 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||||||
text(399, 264, "your machine's thermal fingerprint", 12),
|
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() {
|
steam() {
|
||||||
return [
|
return [
|
||||||
flame(280, 292, 1.3),
|
flame(170, 262, 1.1),
|
||||||
bean(280, 160, 2, C.green, { class: "ac-breathe" }),
|
bean(170, 150, 1.9, C.green, { class: "ac-dry-shift" }),
|
||||||
steamPaths(280, 92, 3),
|
steamWisps(170, 92, 3),
|
||||||
text(120, 90, "energy in →", 13, C.ember, "middle", "700"),
|
text(170, 250, "water out ↑ · heat in ↓", 12, C.ink2),
|
||||||
el("path", { d: "M150 110 q 40 30 78 34", fill: "none", stroke: C.ember, "stroke-width": 2, "marker-end": "" }),
|
// Contained thermometer: the slow, stubborn climb
|
||||||
text(452, 78, "→ water out", 13, C.water, "middle", "700"),
|
el("rect", { x: 392, y: 64, width: 18, height: 140, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||||||
text(280, 270, "evaporation soaks up heat — the endothermic phase", 12.5),
|
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, "43–51% of the roast", 12, C.ink2, "start"),
|
||||||
|
caption("evaporation soaks up energy while the bean fades green → yellow"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
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() {
|
maillard() {
|
||||||
return [
|
return [
|
||||||
bean(180, 150, 2, C.yellow, { class: "ac-maillard-shift" }),
|
bean(160, 148, 1.9, C.yellow, { class: "ac-maillard-shift ac-breathe" }),
|
||||||
...[0, 1, 2, 3, 4].map((i) =>
|
// Chaff flaking off the bean, drifting up-right
|
||||||
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) =>
|
...[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}` }),
|
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",
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
text(392, 90, "chaff lifts away", 12.5, C.ink2, "start"),
|
// Aroma compounds appearing
|
||||||
text(230, 262, "steam + CO₂ swell the bean — airflow carries the chaff out", 12.5),
|
...[[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"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
curve({ shape = "smooth", focus = "all" }) {
|
crack({ phase = "during" } = {}) {
|
||||||
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";
|
const during = phase === "during";
|
||||||
return [
|
return [
|
||||||
bean(230, 150, 2.1, during ? C.brown : C.tan, { crack: true, class: during ? "ac-shake" : "" }),
|
bean(280, 140, 2, during ? C.brown : C.tan, { crack: true, class: during ? "ac-shake" : "" }),
|
||||||
...(during
|
...[0, 1, 2].map((i) =>
|
||||||
? [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}` }),
|
||||||
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}` }),
|
|
||||||
),
|
),
|
||||||
]),
|
// Pressure pushing outward (left), heat released (right)
|
||||||
text(400, 120, during ? "energy released —" : "steam + CO₂", 13, during ? C.ember : C.water, "middle", "700"),
|
...[[-118, "≈8 bar", "of steam inside"], [118, "the exotherm —", "heat released"]].map(([dx, l1, l2], side) =>
|
||||||
text(400, 140, during ? "the exotherm" : "≈8 bar inside", 12.5),
|
el("g", {},
|
||||||
text(230, 272, during ? "cut heat before this moment, gently" : "a pressure vessel about to fail", 12.5),
|
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() {
|
dtr() {
|
||||||
return [
|
return [
|
||||||
el("rect", { x: 60, y: 120, width: 440, height: 40, rx: 8, fill: C.line }),
|
text(280, 60, "one roast, on a timeline", 12.5),
|
||||||
el("rect", { x: 60, y: 120, width: 360, height: 40, rx: 8, fill: C.tan, opacity: 0.5 }),
|
el("rect", { x: 70, y: 110, width: 420, height: 44, rx: 8, fill: C.line }),
|
||||||
el("rect", { x: 420, y: 120, width: 0, height: 40, rx: 8, fill: C.ember, class: "ac-grow-w", style: "--w:80px" }),
|
el("rect", { x: 70, y: 110, width: 280, height: 44, rx: 8, fill: C.tan, opacity: 0.45 }),
|
||||||
el("line", { x1: 420, y1: 100, x2: 420, y2: 180, stroke: C.ink, "stroke-width": 2, "stroke-dasharray": "4 3" }),
|
el("rect", { x: 350, y: 110, width: 0, height: 44, rx: 8, fill: C.ember, class: "ac-grow-w", style: "--w:140px" }),
|
||||||
text(420, 92, "first crack", 12, C.ink, "middle", "700"),
|
el("line", { x1: 350, y1: 92, x2: 350, y2: 172, stroke: C.ink, "stroke-width": 2, "stroke-dasharray": "4 3" }),
|
||||||
text(460, 145, "DTR", 13, C.paper, "middle", "700"),
|
text(350, 84, "first crack", 12, C.ink, "middle", "700"),
|
||||||
text(280, 210, "development ÷ total time = 12–20%", 13.5, C.ember, "middle", "700"),
|
text(210, 138, "drying + Maillard", 12.5, C.ink2),
|
||||||
text(280, 235, "the strongest single lever on the cup", 12.5),
|
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 = 12–20%", 14, C.ember, "middle", "700"),
|
||||||
|
caption("the strongest single lever on the cup — the app computes it from every log"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
weightLoss() {
|
weightLoss() {
|
||||||
return [
|
return [
|
||||||
bean(170, 130, 1.5, C.green), text(170, 205, "250 g green", 13),
|
bean(150, 128, 1.5, C.green),
|
||||||
el("path", { d: "M240 130 h 70", stroke: C.ink2, "stroke-width": 2, class: "ac-draw" }),
|
text(150, 208, "250 g green", 13, C.ink, "middle", "700"),
|
||||||
el("path", { d: "M304 124 l 10 6 l -10 6 Z", fill: C.ink2 }),
|
el("line", { x1: 226, y1: 128, x2: 302, y2: 128, stroke: C.ink2, "stroke-width": 2.5, class: "ac-draw" }),
|
||||||
bean(380, 130, 1.62, C.brown, { crack: true }), text(380, 205, "≈220 g roasted", 13),
|
el("path", { d: "M300 121 l 12 7 l -12 7 Z", fill: C.ink2 }),
|
||||||
text(280, 250, "−11–13% for a light roast — the honest number", 13, C.ember, "middle", "700"),
|
bean(390, 128, 1.62, C.brown, { crack: true }),
|
||||||
text(280, 272, "time, temperature and development, measured by a kitchen scale", 12),
|
text(390, 208, "≈220 g roasted", 13, C.ink, "middle", "700"),
|
||||||
|
text(280, 244, "−11–13% for a light roast", 13.5, C.ember, "middle", "700"),
|
||||||
|
caption("time, temperature and development — measured by a kitchen scale"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
ror({ shape }) {
|
curve({ focus = "all" } = {}) {
|
||||||
const paths = {
|
const band = (x, w, color, key) =>
|
||||||
smooth: "M60 90 C 150 130, 300 170, 500 195",
|
el("rect", { x, y: 62, width: w, height: 168, fill: color, opacity: focus === "all" || focus === key ? 0.16 : 0.05 });
|
||||||
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 [
|
return [
|
||||||
el("path", { d: "M60 230 H 500 M60 230 V 60", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
band(60, 150, C.yellow, "drying"),
|
||||||
text(46, 90, "RoR", 11.5, C.ink2, "end"),
|
band(210, 158, C.tan, "maillard"),
|
||||||
el("path", { d: paths.smooth, fill: "none", stroke: C.line, "stroke-width": 2, "stroke-dasharray": "5 4" }),
|
band(368, 132, C.brown, "development"),
|
||||||
el("path", { d: paths[shape], fill: "none", stroke: color, "stroke-width": 3, class: "ac-draw" }),
|
el("path", { d: "M60 230 H 500 M60 230 V 62", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||||||
shape === "crash" ? el("circle", { cx: 380, cy: 228, r: 6, fill: C.fail, class: "ac-pulse" }) : el("g"),
|
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" }),
|
||||||
shape === "stall" ? el("rect", { x: 280, y: 170, width: 140, height: 16, fill: C.fail, opacity: 0.12 }) : el("g"),
|
el("circle", { cx: 100, cy: 207, r: 4.5, fill: C.ember }),
|
||||||
text(280, 268, note, 13, color, "middle", "700"),
|
text(100, 250, "TP", 11.5, C.ink2),
|
||||||
|
el("circle", { cx: 375, cy: 79, r: 5, fill: C.ink, class: "ac-pulse" }),
|
||||||
|
text(375, 56, "first crack", 12, C.ink, "middle", "700"),
|
||||||
|
text(135, 250, "drying", 12), text(289, 250, "Maillard", 12), text(434, 250, "development", 12),
|
||||||
|
caption("bean temperature — dip, turn, climb, crack, drop: the story of the roast"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
symptom({ kind }) {
|
ror() {
|
||||||
if (kind === "surface")
|
|
||||||
return [
|
return [
|
||||||
bean(180, 140, 1.7, C.brown),
|
el("path", { d: "M60 230 H 500 M60 230 V 62", stroke: C.line, "stroke-width": 1.5, fill: "none" }),
|
||||||
el("circle", { cx: 180, cy: 84, r: 9, fill: C.dark, class: "ac-pulse" }),
|
text(52, 76, "RoR", 11.5, C.ink2, "end"),
|
||||||
el("circle", { cx: 180, cy: 196, r: 9, fill: C.dark, class: "ac-pulse ac-delay-1" }),
|
// The ideal: steady decline
|
||||||
text(180, 240, "tipping — ends scorched", 12.5),
|
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" }),
|
||||||
bean(390, 140, 1.7, C.brown),
|
text(150, 106, "ideal: always falling", 12, C.pass, "middle", "700"),
|
||||||
el("ellipse", { cx: 372, cy: 132, rx: 16, ry: 24, fill: C.dark, opacity: 0.8, class: "ac-pulse ac-delay-2" }),
|
// The failure: crash, then flick
|
||||||
text(390, 240, "scorching — drum-face burn", 12.5),
|
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" }),
|
||||||
text(280, 278, "machine settings, not timing: lower charge, more airflow, smaller batch", 12),
|
el("circle", { cx: 352, cy: 224, r: 5.5, fill: C.fail, class: "ac-pulse" }),
|
||||||
];
|
text(340, 252, "crash", 12, C.fail, "middle", "700"),
|
||||||
const fast = kind === "fast";
|
el("circle", { cx: 420, cy: 179, r: 5.5, fill: C.fail, class: "ac-pulse ac-delay-1" }),
|
||||||
return [
|
text(432, 160, "flick", 12, C.fail, "middle", "700"),
|
||||||
bean(160, 140, 1.9, fast ? "#c9a76a" : C.dark, { crack: fast }),
|
// The stall
|
||||||
text(160, 240, fast ? "pale centre, papery cup" : "florals spent, savory cup", 12.5),
|
el("line", { x1: 200, y1: 148, x2: 300, y2: 148, stroke: C.warnColor ?? "#a8741a", "stroke-width": 3, "stroke-dasharray": "2 4" }),
|
||||||
el("path", { d: "M300 140 h 110", stroke: C.ink2, "stroke-width": 2 }),
|
text(250, 136, "stall = baking", 12, "#a8741a", "middle", "700"),
|
||||||
el("path", { d: "M404 134 l 10 6 l -10 6 Z", fill: C.ink2 }),
|
caption("crash bakes out sweetness · flick tastes roasty · the fix is always upstream"),
|
||||||
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() {
|
oneChange() {
|
||||||
|
const cards = [
|
||||||
|
["batch 1", "baseline", false],
|
||||||
|
["batch 2", "FC +0:30", false],
|
||||||
|
["batch 3", "dev +0:15", true],
|
||||||
|
];
|
||||||
return [
|
return [
|
||||||
...[0, 1, 2].map((i) =>
|
...cards.map(([label, change, active], i) =>
|
||||||
el("g", {},
|
el("g", { class: active ? "ac-pulse-soft" : "" },
|
||||||
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 }),
|
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(145 + i * 140, 118, `batch ${i + 1}`, 12.5, C.ink2),
|
text(142 + i * 146, 106, label, 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"),
|
text(142 + i * 146, 134, change, 13.5, active ? 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" }),
|
el("path", { d: "M202 116 h 24 M348 116 h 24", 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, 196, "papery → FC +0:30 · savory → FC −0:30 · sharp → dev +0:15", 12.5, C.ink),
|
||||||
text(280, 234, "the app carries your note into the next plan's ± Refine", 12.5),
|
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() {
|
dissolve() {
|
||||||
const order = [
|
|
||||||
["acids — bright, sour", C.yellow, 0],
|
|
||||||
["sugars — sweet", C.tan, 1],
|
|
||||||
["bitters — heavy, dry", C.dark, 2],
|
|
||||||
];
|
|
||||||
return [
|
return [
|
||||||
el("circle", { cx: 150, cy: 150, r: 52, fill: C.brown }),
|
el("circle", { cx: 150, cy: 140, r: 52, fill: C.brown }),
|
||||||
...[...Array(9)].map((_, i) =>
|
text(150, 218, "ground coffee", 12, C.ink2),
|
||||||
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}` }),
|
...[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}`,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
...order.map(([label, color, i]) =>
|
...[
|
||||||
|
["1. acids — bright, sour", C.yellow],
|
||||||
|
["2. sugars — sweet", C.tan],
|
||||||
|
["3. bitters — heavy, dry", C.dark],
|
||||||
|
].map(([label, color], i) =>
|
||||||
el("g", {},
|
el("g", {},
|
||||||
el("circle", { cx: 300, cy: 100 + i * 46, r: 8, fill: color }),
|
el("circle", { cx: 312, cy: 96 + i * 44, r: 8, fill: color }),
|
||||||
text(320, 105 + i * 46, `${i + 1}. ${label}`, 13, C.ink2, "start"),
|
text(330, 101 + i * 44, label, 13, C.ink2, "start"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
text(280, 262, "hot water dissolves them in this order — brewing is choosing when to stop", 12.5),
|
caption("hot water dissolves them in this order — brewing is choosing when to stop"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
yieldMap() {
|
yieldMap() {
|
||||||
return [
|
return [
|
||||||
el("rect", { x: 90, y: 70, width: 380, height: 150, rx: 8, fill: C.paper, stroke: C.line }),
|
text(150, 92, "sour · thin", 12.5, C.fail, "middle", "700"),
|
||||||
el("rect", { x: 218, y: 70, width: 124, height: 150, fill: C.pass, opacity: 0.14 }),
|
text(280, 78, "sweet spot", 13, C.pass, "middle", "700"),
|
||||||
text(280, 60, "extraction yield →", 12.5),
|
text(280, 96, "18–22%", 13, C.pass, "middle", "700"),
|
||||||
text(150, 150, "sour\nthin", 13, C.fail),
|
text(412, 92, "bitter · drying", 12.5, C.fail, "middle", "700"),
|
||||||
text(280, 140, "sweet spot", 13.5, C.pass, "middle", "700"),
|
el("rect", { x: 90, y: 112, width: 380, height: 56, rx: 10, fill: C.paper, stroke: C.line }),
|
||||||
text(280, 160, "18–22%", 13, C.pass, "middle", "700"),
|
el("rect", { x: 90, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
|
||||||
text(415, 150, "bitter\ndrying", 13, C.fail),
|
el("rect", { x: 342, y: 112, width: 128, height: 56, rx: 10, fill: C.fail, opacity: 0.1 }),
|
||||||
el("circle", { cx: 280, cy: 190, r: 6, fill: C.ember, class: "ac-slide-x" }),
|
el("rect", { x: 218, y: 112, width: 124, height: 56, fill: C.pass, opacity: 0.16 }),
|
||||||
text(280, 250, "strength = how much coffee is in the water · extraction = how much you took from the grounds", 11.5),
|
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() {
|
grind() {
|
||||||
return [
|
const pile = (x, label, sub, circles) => {
|
||||||
...[["coarse", 26, 3, 130], ["medium", 15, 8, 280], ["fine", 8, 18, 430]].map(([name, r, n, x]) => {
|
|
||||||
const g = el("g", {});
|
const g = el("g", {});
|
||||||
for (let i = 0; i < n; i++)
|
for (const [dx, dy, r] of circles) g.append(el("circle", { cx: x + dx, cy: 128 + dy, r, fill: C.brown }));
|
||||||
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, 196, label, 13, C.ink, "middle", "700"), text(x, 216, sub, 11.5, C.ink2));
|
||||||
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;
|
return g;
|
||||||
}),
|
};
|
||||||
text(280, 66, "same mass of coffee — very different surface area", 13),
|
return [
|
||||||
text(280, 278, "grind is the master dial: it moves extraction and contact time together", 12.5),
|
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() {
|
tempTime() {
|
||||||
return [
|
return [
|
||||||
el("g", {},
|
// Temperature: contained thermometer
|
||||||
el("rect", { x: 100, y: 84, width: 18, height: 120, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
el("rect", { x: 108, y: 68, width: 18, height: 118, rx: 9, fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||||||
el("circle", { cx: 109, cy: 214, r: 15, fill: C.ember }),
|
el("circle", { cx: 117, cy: 198, r: 13, fill: C.ember }),
|
||||||
el("rect", { x: 103, y: 110, width: 12, height: 100, rx: 6, fill: C.ember, class: "ac-grow-h" }),
|
el("rect", { x: 112, y: 76, width: 10, height: 106, rx: 5, fill: C.ember, class: "ac-fill-up" }),
|
||||||
text(109, 250, "90–96 °C", 12.5)),
|
text(117, 240, "90–96 °C", 12.5, C.ink, "middle", "700"),
|
||||||
el("g", {},
|
// Time: a gauge whose needle sweeps
|
||||||
el("circle", { cx: 280, cy: 145, r: 55, fill: "none", stroke: C.ink2, "stroke-width": 3 }),
|
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: 145, x2: 280, y2: 104, stroke: C.ember, "stroke-width": 3, class: "ac-spin-slow", "transform-origin": "280px 145px" }),
|
el("line", { x1: 280, y1: 190, x2: 280, y2: 138, stroke: C.ink, "stroke-width": 4, class: "ac-needle", "transform-origin": "280px 190px" }),
|
||||||
text(280, 250, "time = how far down the list", 12.5)),
|
el("circle", { cx: 280, cy: 190, r: 8, fill: C.ink }),
|
||||||
el("g", {},
|
text(280, 240, "time = how far down the list", 12.5, C.ink, "middle", "700"),
|
||||||
bean(420, 118, 0.7, C.brown), text(448, 122, "1 :", 15, C.ink, "start", "700"),
|
// Ratio: one bean to many drops
|
||||||
...[0, 1, 2].map((i) => el("circle", { cx: 478 + i * 16, cy: 118, r: 6, fill: C.water, class: `ac-pulse ac-delay-${i}` })),
|
bean(420, 110, 0.62, C.brown),
|
||||||
text(452, 250, "ratio 1:15–1:17", 12.5, C.ink2)),
|
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:15–1:17", 12.5, C.ink, "middle", "700"),
|
||||||
|
caption("three dials on one cup — and grind, from the previous slide, is the fourth"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
tasteDial() {
|
tasteDial() {
|
||||||
return [
|
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: "M130 198 A 156 156 0 0 1 430 198", fill: "none", stroke: C.line, "stroke-width": 15, "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: "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: "M320 64 A 160 160 0 0 1 440 200", fill: "none", stroke: C.fail, "stroke-width": 16, "stroke-linecap": "round", opacity: 0.55 }),
|
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(120, 240, "sour · sharp", 13, C.fail),
|
text(126, 232, "sour · sharp", 12.5, C.fail, "middle", "700"),
|
||||||
text(280, 52, "sweet & balanced", 13, C.pass, "middle", "700"),
|
text(280, 48, "sweet & balanced", 13, C.pass, "middle", "700"),
|
||||||
text(440, 240, "bitter · drying", 13, C.fail),
|
text(434, 232, "bitter · drying", 12.5, C.fail, "middle", "700"),
|
||||||
el("line", { x1: 280, y1: 200, x2: 280, y2: 90, stroke: C.ink, "stroke-width": 4, class: "ac-dial", "transform-origin": "280px 200px" }),
|
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: 200, r: 10, fill: C.ink }),
|
el("circle", { cx: 280, cy: 198, r: 10, fill: C.ink }),
|
||||||
text(280, 268, "extract more ← grind finer · hotter · longer → extract less", 12.5),
|
text(280, 246, "sour? extract more · bitter? extract less", 13, C.ink, "middle", "700"),
|
||||||
|
caption("finer, hotter, longer ← the dial → coarser, cooler, shorter"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
bloom() {
|
bloom() {
|
||||||
|
const [bx, by] = [280, 118];
|
||||||
return [
|
return [
|
||||||
silhouette("v60", 210, 60, 2.2, C.ink2),
|
silhouette("v60"),
|
||||||
|
// CO₂ bubbles rising out of the cone
|
||||||
...[0, 1, 2, 3].map((i) =>
|
...[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}` }),
|
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, 110, "CO₂ escaping", 12.5, C.tan, "start"),
|
text(430, 96, "CO₂ escaping —", 12.5, C.tan, "start", "700"),
|
||||||
text(430, 132, "the bed swells & bubbles", 12.5, C.ink2, "start"),
|
text(430, 114, "the bed swells", 12.5, C.ink2, "start"),
|
||||||
text(280, 262, "2× the coffee's weight in water · wait 30–45 s", 13, C.ember, "middle", "700"),
|
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 30–45 s", 12.5, C.ember, "end", "700"),
|
||||||
|
caption("gas pushes water away from coffee — bloom first, then brew"),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
tamp() {
|
tamp() {
|
||||||
return [
|
return [
|
||||||
el("rect", { x: 200, y: 100, width: 160, height: 60, rx: 8, fill: C.ink2 }),
|
// Tamper descending onto the basket
|
||||||
el("rect", { x: 210, y: 110, width: 140, height: 42, rx: 4, fill: C.brown }),
|
el("g", { class: "ac-tamp" },
|
||||||
el("rect", { x: 240, y: 40, width: 80, height: 40, rx: 6, fill: C.ink, class: "ac-tamp" }),
|
el("rect", { x: 262, y: 44, width: 16, height: 26, rx: 5, fill: C.ink }),
|
||||||
el("rect", { x: 270, y: 20, width: 20, height: 26, rx: 6, fill: C.ink }),
|
el("rect", { x: 238, y: 70, width: 64, height: 20, rx: 5, 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}` })),
|
el("rect", { x: 206, y: 118, width: 148, height: 54, rx: 8, fill: C.ink2 }),
|
||||||
text(280, 220, "dose to 0.1 g · distribute · tamp level", 13, C.ink, "middle", "700"),
|
el("rect", { x: 216, y: 128, width: 128, height: 36, rx: 4, fill: C.brown }),
|
||||||
text(280, 244, "pressurized water exploits any weakness — puck prep is channel prevention", 12),
|
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() {
|
shot() {
|
||||||
return [
|
return [
|
||||||
el("rect", { x: 230, y: 60, width: 100, height: 34, rx: 6, fill: C.ink }),
|
el("rect", { x: 230, y: 56, width: 100, height: 32, 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("line", { x1: 268, y1: 88, x2: 268, y2: 122, 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("line", { x1: 292, y1: 88, x2: 292, y2: 122, stroke: C.brown, "stroke-width": 5, class: "ac-drip ac-delay-1" }),
|
||||||
el("rect", { x: 250, y: 152, width: 60, height: 0, fill: C.tan, class: "ac-grow-h-down" }),
|
el("path", { d: "M248 130 h 64 l -7 40 h -50 Z", fill: C.paper, stroke: C.ink2, "stroke-width": 2 }),
|
||||||
text(400, 110, "18 g in → 36 g out", 13.5, C.ink, "start", "700"),
|
el("rect", { x: 252, y: 146, width: 56, height: 0, fill: C.tan, class: "ac-grow-h-down" }),
|
||||||
text(400, 134, "25–32 seconds", 13, C.ink2, "start"),
|
text(392, 108, "18 g in → 36 g out", 13, C.ink, "start", "700"),
|
||||||
text(400, 158, "flows like warm honey", 12.5, C.tan, "start"),
|
text(392, 130, "in 25–32 seconds", 12.5, C.ink2, "start"),
|
||||||
text(280, 240, "blonde & gushing → finer · choked drips → coarser", 12.5),
|
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 }) {
|
brewAnim({ method, mode }) {
|
||||||
const m = findBrewMethod(method);
|
const m = findBrewMethod(method);
|
||||||
const out = [
|
const [bx, by] = sil(...(BOWL[method] ?? [32, 30]));
|
||||||
silhouette(method, 190, 40, 2.8, C.ink2),
|
const out = [silhouette(method), text(280, 262, m?.name ?? method, 13.5, C.ink, "middle", "700")];
|
||||||
text(280, 280, 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")
|
|
||||||
|
if (mode === "steep" || mode === "valve") {
|
||||||
out.push(
|
out.push(
|
||||||
...[...Array(6)].map((_, i) =>
|
...[...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}` }),
|
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}`,
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
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")
|
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(
|
out.push(
|
||||||
el("path", { d: "M275 70 v 50", stroke: C.ember, "stroke-width": 5, class: "ac-tamp" }),
|
el("g", { 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"),
|
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" })),
|
||||||
);
|
);
|
||||||
if (mode === "pour" || mode === "spiral" || mode === "drip")
|
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(
|
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) =>
|
||||||
...[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}` })),
|
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}` }),
|
||||||
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")
|
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(
|
out.push(
|
||||||
el("path", { d: "M150 90 h 30", stroke: C.ember, "stroke-width": 5, class: "ac-pulse" }),
|
...[0, 1, 2].map((i) =>
|
||||||
text(140, 70, "9 bar", 13.5, C.ember, "middle", "700"),
|
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}` }),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (mode === "flame") out.push(flame(280, 292, 1.1), text(430, 150, "≈1.5 bar of boiler steam", 12.5, C.ink2, "start"));
|
hint(["9 bar pushes", "water through", "the puck"], C.ember);
|
||||||
|
}
|
||||||
|
if (mode === "flame") {
|
||||||
|
out.push(flame(bx, sil(32, 66)[1], 1));
|
||||||
|
hint(["≈1.5 bar of", "boiler steam", "pushes upward"]);
|
||||||
|
}
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Progress ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PROGRESS_KEY = "academyProgress.v1";
|
||||||
|
function loadProgress() {
|
||||||
|
try {
|
||||||
|
return JSON.parse(localStorage.getItem(PROGRESS_KEY)) ?? {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveProgress(lessonId, patch) {
|
||||||
|
const all = loadProgress();
|
||||||
|
all[lessonId] = { ...all[lessonId], ...patch };
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PROGRESS_KEY, JSON.stringify(all));
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Player ─────────────────────────────────────────────────────────────────
|
// ── Player ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
let current = null; // {track, lesson, index}
|
let current = null; // {track, lesson, index}
|
||||||
@@ -492,6 +534,7 @@ let autoplay = true;
|
|||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
function renderMenu() {
|
function renderMenu() {
|
||||||
|
const progress = loadProgress();
|
||||||
const mount = $("academy-menu");
|
const mount = $("academy-menu");
|
||||||
mount.replaceChildren(
|
mount.replaceChildren(
|
||||||
...ACADEMY_TRACKS.map((track) => {
|
...ACADEMY_TRACKS.map((track) => {
|
||||||
@@ -512,20 +555,38 @@ function renderMenu() {
|
|||||||
const list = document.createElement("div");
|
const list = document.createElement("div");
|
||||||
list.className = "academy-lessons";
|
list.className = "academy-lessons";
|
||||||
track.lessons.forEach((lesson, li) => {
|
track.lessons.forEach((lesson, li) => {
|
||||||
|
const state = progress[lesson.id] ?? {};
|
||||||
|
const resumeAt = state.done ? 0 : (state.slide ?? 0);
|
||||||
const btn = document.createElement("button");
|
const btn = document.createElement("button");
|
||||||
btn.type = "button";
|
btn.type = "button";
|
||||||
btn.className = "academy-lesson";
|
btn.className = "academy-lesson";
|
||||||
const num = document.createElement("span");
|
const num = document.createElement("span");
|
||||||
num.className = "academy-lesson-num";
|
num.className = "academy-lesson-num";
|
||||||
num.textContent = String(li + 1);
|
num.textContent = state.done ? "✓" : String(li + 1);
|
||||||
|
if (state.done) num.classList.add("done");
|
||||||
|
const mid = document.createElement("span");
|
||||||
|
mid.className = "academy-lesson-mid";
|
||||||
const label = document.createElement("span");
|
const label = document.createElement("span");
|
||||||
label.className = "academy-lesson-label";
|
label.className = "academy-lesson-label";
|
||||||
label.textContent = lesson.title;
|
label.textContent = lesson.title;
|
||||||
const meta = document.createElement("span");
|
const meta = document.createElement("span");
|
||||||
meta.className = "academy-lesson-meta";
|
meta.className = "academy-lesson-meta";
|
||||||
meta.textContent = `${lesson.slides.length} slides · ~${lesson.minutes} min`;
|
meta.textContent = state.done
|
||||||
btn.append(num, label, meta);
|
? `completed · ${lesson.slides.length} slides · ~${lesson.minutes} min`
|
||||||
btn.addEventListener("click", () => openLesson(track, lesson, 0));
|
: resumeAt > 0
|
||||||
|
? `resume at slide ${resumeAt + 1} of ${lesson.slides.length}`
|
||||||
|
: `${lesson.slides.length} slides · ~${lesson.minutes} min`;
|
||||||
|
const bar = document.createElement("span");
|
||||||
|
bar.className = "academy-progress";
|
||||||
|
const fill = document.createElement("span");
|
||||||
|
fill.className = "academy-progress-fill";
|
||||||
|
fill.style.width = state.done
|
||||||
|
? "100%"
|
||||||
|
: `${Math.round((resumeAt / lesson.slides.length) * 100)}%`;
|
||||||
|
bar.append(fill);
|
||||||
|
mid.append(label, meta, bar);
|
||||||
|
btn.append(num, mid);
|
||||||
|
btn.addEventListener("click", () => openLesson(track, lesson, resumeAt));
|
||||||
list.append(btn);
|
list.append(btn);
|
||||||
});
|
});
|
||||||
body.append(list);
|
body.append(list);
|
||||||
@@ -547,20 +608,22 @@ function closeLesson() {
|
|||||||
current = null;
|
current = null;
|
||||||
$("academy-player").classList.add("hidden");
|
$("academy-player").classList.add("hidden");
|
||||||
$("academy-menu").classList.remove("hidden");
|
$("academy-menu").classList.remove("hidden");
|
||||||
|
renderMenu(); // reflect fresh progress
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderSlide() {
|
function renderSlide() {
|
||||||
const { lesson, index, track } = current;
|
const { lesson, index, track } = current;
|
||||||
const slide = lesson.slides[index];
|
const slide = lesson.slides[index];
|
||||||
|
saveProgress(lesson.id, { slide: index });
|
||||||
$("player-lesson").textContent = `${track.name} — ${lesson.title}`;
|
$("player-lesson").textContent = `${track.name} — ${lesson.title}`;
|
||||||
$("player-slide-title").textContent = slide.title;
|
$("player-slide-title").textContent = slide.title;
|
||||||
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
|
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
|
||||||
|
|
||||||
// Scene: rebuild so its CSS animations restart with the slide.
|
// Rebuilt per slide so CSS animations restart with it.
|
||||||
const svg = $("player-scene");
|
const svg = $("player-scene");
|
||||||
svg.replaceChildren();
|
svg.replaceChildren();
|
||||||
try {
|
try {
|
||||||
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}, svg));
|
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
|
||||||
} catch {
|
} catch {
|
||||||
svg.append(text(280, 150, slide.title, 16, C.ink2));
|
svg.append(text(280, 150, slide.title, 16, C.ink2));
|
||||||
}
|
}
|
||||||
@@ -598,6 +661,7 @@ function setPlayState(playing) {
|
|||||||
function next() {
|
function next() {
|
||||||
const { lesson, index } = current;
|
const { lesson, index } = current;
|
||||||
if (index >= lesson.slides.length - 1) {
|
if (index >= lesson.slides.length - 1) {
|
||||||
|
saveProgress(lesson.id, { done: true, slide: 0 });
|
||||||
closeLesson();
|
closeLesson();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user