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

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

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

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

223 lines
7.0 KiB
JavaScript

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 { SCENES, sceneFallbackText } from "./academy-scenes.js?v=__ASSET_VERSION__";
// ── 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 ─────────────────────────────────────────────────────────────────
let current = null; // {track, lesson, index}
const audio = new Audio();
let autoplay = true;
const $ = (id) => document.getElementById(id);
function renderMenu() {
const progress = loadProgress();
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 state = progress[lesson.id] ?? {};
const resumeAt = state.done ? 0 : (state.slide ?? 0);
const btn = document.createElement("button");
btn.type = "button";
btn.className = "academy-lesson";
const num = document.createElement("span");
num.className = "academy-lesson-num";
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");
label.className = "academy-lesson-label";
label.textContent = lesson.title;
const meta = document.createElement("span");
meta.className = "academy-lesson-meta";
meta.textContent = state.done
? `completed · ${lesson.slides.length} slides · ~${lesson.minutes} min`
: 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);
});
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");
renderMenu(); // reflect fresh progress
}
function renderSlide() {
const { lesson, index, track } = current;
const slide = lesson.slides[index];
saveProgress(lesson.id, { slide: index });
$("player-lesson").textContent = `${track.name}${lesson.title}`;
$("player-slide-title").textContent = slide.title;
$("player-count").textContent = `${index + 1} / ${lesson.slides.length}`;
// Rebuilt per slide so CSS animations restart with it.
const svg = $("player-scene");
svg.replaceChildren();
try {
svg.append(...SCENES[slide.scene[0]](slide.scene[1] ?? {}));
} catch {
svg.append(sceneFallbackText(slide.title));
}
$("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) {
saveProgress(lesson.id, { done: true, slide: 0 });
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();