diff --git a/docs/assets/learner-journey-campus.png b/docs/assets/learner-journey-campus.png new file mode 100644 index 0000000..8753367 Binary files /dev/null and b/docs/assets/learner-journey-campus.png differ diff --git a/docs/index.html b/docs/index.html index d7b2b57..a69bad3 100644 --- a/docs/index.html +++ b/docs/index.html @@ -29,6 +29,7 @@ Admissions Craft Campus + Journey Viewer Graduation @@ -208,6 +209,22 @@

The MCP response keeps the learner moving and visible.< +
+
+

Learner Journey Viewer

+

Give the human a visible campus path while the model studies.

+

+ The interactive viewer turns anonymous course progress into a playful 16-bit campus route: + freshman enrollment, workshop practice, architecture growth, and graduation. It ships with a + demo save file and can load real progress from a public enrollment_key when the + page can reach the MCP endpoint. +

+ +
+
+

Registrar

diff --git a/docs/learner-journey.css b/docs/learner-journey.css new file mode 100644 index 0000000..021fed0 --- /dev/null +++ b/docs/learner-journey.css @@ -0,0 +1,464 @@ +:root { + --ink: #f8f0c8; + --muted: #c7b981; + --panel: rgba(16, 22, 38, 0.88); + --panel-strong: rgba(8, 12, 23, 0.94); + --line: rgba(255, 224, 128, 0.28); + --gold: #ffd45e; + --green: #67f0a3; + --blue: #7bd7ff; + --rose: #ff8ca6; + --shadow: rgba(0, 0, 0, 0.55); + color-scheme: dark; + font-family: "SFMono-Regular", "Cascadia Mono", "Liberation Mono", Menlo, Consolas, monospace; +} + +* { + box-sizing: border-box; +} + +html { + scroll-behavior: smooth; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 20% 12%, rgba(123, 215, 255, 0.16), transparent 28rem), + radial-gradient(circle at 78% 6%, rgba(255, 212, 94, 0.14), transparent 24rem), + linear-gradient(180deg, #09111f 0%, #131827 52%, #070a12 100%); + color: var(--ink); +} + +body::after { + position: fixed; + inset: 0; + z-index: 30; + pointer-events: none; + content: ""; + background: + linear-gradient(rgba(255, 255, 255, 0.035) 50%, rgba(0, 0, 0, 0.075) 50%), + linear-gradient(90deg, rgba(255, 0, 0, 0.025), rgba(0, 255, 120, 0.018), rgba(0, 95, 255, 0.025)); + background-size: 100% 4px, 6px 100%; + mix-blend-mode: screen; + opacity: 0.28; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +a:focus-visible { + outline: 3px solid var(--blue); + outline-offset: 4px; +} + +.skip-link { + position: absolute; + left: 1rem; + top: 1rem; + z-index: 60; + transform: translateY(-200%); + padding: 0.62rem 0.9rem; + border: 1px solid var(--green); + background: #020605; + color: var(--green); + text-decoration: none; +} + +.skip-link:focus { + transform: translateY(0); +} + +.journey-app { + display: grid; + grid-template-columns: minmax(0, 1fr) 380px; + min-height: 100vh; +} + +.map-shell { + position: relative; + min-height: 100vh; + overflow: hidden; + border-right: 1px solid var(--line); +} + +.campus-map { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + filter: saturate(1.08) contrast(1.04); +} + +.map-shell::after { + position: absolute; + inset: 0; + pointer-events: none; + content: ""; + background: radial-gradient(circle, transparent 58%, rgba(0, 0, 0, 0.5) 100%); +} + +.map-hud { + position: absolute; + left: 24px; + top: 22px; + z-index: 2; + width: min(540px, calc(100% - 48px)); + padding: 16px; + border: 1px solid var(--line); + background: var(--panel); + box-shadow: 0 18px 48px var(--shadow); +} + +.eyebrow { + margin: 0 0 0.42rem; + color: var(--green); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.map-hud h1 { + margin: 0 0 8px; + color: var(--gold); + font-size: clamp(1.35rem, 2vw, 2rem); + letter-spacing: 0; + line-height: 1.1; + text-shadow: 2px 2px 0 #1a1a22; +} + +.map-hud p:not(.eyebrow) { + margin: 0; + font-size: 0.9rem; + line-height: 1.5; +} + +.path-stop { + position: absolute; + z-index: 3; + width: 46px; + height: 46px; + display: grid; + place-items: center; + padding: 0; + transform: translate(-50%, -50%); + border: 2px solid rgba(255, 255, 255, 0.7); + background: rgba(10, 13, 24, 0.78); + color: #fff; + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.28), 0 10px 22px var(--shadow); + transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; +} + +.path-stop:hover { + transform: translate(-50%, -50%) scale(1.1); + border-color: var(--gold); + background: rgba(42, 35, 18, 0.92); +} + +.path-stop[data-state="passed"] { + border-color: var(--green); + color: var(--green); +} + +.path-stop[data-state="active"] { + border-color: var(--gold); + color: var(--gold); + animation: pulse 1.2s infinite steps(2, end); +} + +.avatar { + position: absolute; + z-index: 4; + left: 58%; + top: 46%; + width: 58px; + height: 58px; + display: grid; + place-items: center; + transform: translate(-50%, -50%); + border: 2px solid var(--gold); + background: rgba(2, 6, 12, 0.76); + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.32), 0 0 26px rgba(255, 212, 94, 0.4); + animation: bob 1s infinite steps(2, end); +} + +.avatar.walking { + filter: drop-shadow(0 0 12px rgba(255, 212, 94, 0.72)); + animation: walk-bob 420ms infinite steps(2, end); +} + +.avatar span { + color: var(--gold); + font-size: 1.45rem; + font-weight: 900; +} + +.sidebar { + position: relative; + z-index: 5; + padding: 20px; + overflow-y: auto; + background: linear-gradient(180deg, rgba(11, 14, 25, 0.98), rgba(18, 23, 39, 0.98)); +} + +.panel { + margin-bottom: 14px; + padding: 14px; + border: 1px solid var(--line); + background: var(--panel-strong); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.34); +} + +.panel-label { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; + color: var(--muted); + font-size: 0.75rem; + text-transform: uppercase; +} + +.rank-card { + display: grid; + grid-template-columns: 58px minmax(0, 1fr); + gap: 12px; + align-items: center; +} + +.portrait { + width: 58px; + aspect-ratio: 1; + display: grid; + place-items: center; + border: 2px solid var(--gold); + background: #161d32; + color: var(--gold); + font-weight: 900; +} + +.rank-card h2, +.stop-card h2 { + margin: 0 0 6px; + color: var(--gold); + font-size: 1.12rem; + line-height: 1.25; +} + +.rank-card p, +.stop-card p, +.hint { + margin: 0; + color: var(--ink); + font-size: 0.82rem; + line-height: 1.45; +} + +.hint { + margin-top: 0.8rem; + color: var(--muted); +} + +.meter { + height: 16px; + margin-top: 12px; + padding: 2px; + border: 1px solid var(--line); + background: #070a12; +} + +.meter span { + display: block; + width: 7%; + height: 100%; + background: linear-gradient(90deg, var(--green), var(--gold)); +} + +.registrar-form { + display: grid; + gap: 8px; +} + +.registrar-form label { + color: var(--muted); + font-size: 0.76rem; + text-transform: uppercase; +} + +.registrar-form input { + width: 100%; + min-height: 40px; + padding: 0.55rem 0.65rem; + border: 1px solid rgba(255, 255, 255, 0.16); + background: rgba(255, 255, 255, 0.06); + color: var(--ink); +} + +.registrar-form button, +.controls button { + min-height: 42px; + border: 1px solid var(--line); + background: #182039; + color: var(--ink); +} + +.registrar-form button:hover, +.controls button:hover { + border-color: var(--gold); + color: var(--gold); +} + +.badges { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.badge { + min-height: 70px; + padding: 10px; + border: 1px solid rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.04); + color: var(--muted); + font-size: 0.75rem; + line-height: 1.35; +} + +.badge strong { + display: block; + margin-bottom: 4px; + color: var(--ink); + font-size: 0.82rem; +} + +.badge.unlocked { + border-color: rgba(103, 240, 163, 0.48); + background: rgba(103, 240, 163, 0.08); +} + +.quest-list { + display: grid; + gap: 8px; +} + +.quest { + display: grid; + grid-template-columns: 26px minmax(0, 1fr); + gap: 8px; + align-items: center; + padding: 9px; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(255, 255, 255, 0.035); + color: var(--ink); + font-size: 0.82rem; +} + +.quest small { + display: block; + margin-top: 2px; + color: var(--muted); + font-size: 0.7rem; +} + +.controls { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} + +@keyframes bob { + 0%, + 100% { + margin-top: 0; + } + + 50% { + margin-top: -8px; + } +} + +@keyframes walk-bob { + 0%, + 100% { + margin-top: -2px; + transform: translateX(-2px); + } + + 50% { + margin-top: -10px; + transform: translateX(2px); + } +} + +@keyframes pulse { + 0%, + 100% { + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.28), 0 0 0 rgba(255, 212, 94, 0); + } + + 50% { + box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.28), 0 0 30px rgba(255, 212, 94, 0.72); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.001ms !important; + } +} + +@media (max-width: 980px) { + .journey-app { + grid-template-columns: 1fr; + } + + .map-shell { + min-height: 68vh; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .sidebar { + min-height: 32vh; + } +} + +@media (max-width: 620px) { + .map-hud { + left: 12px; + top: 12px; + width: calc(100% - 24px); + } + + .path-stop { + width: 40px; + height: 40px; + font-size: 0.76rem; + } + + .avatar { + width: 48px; + height: 48px; + } + + .sidebar { + padding: 12px; + } +} diff --git a/docs/learner-journey.html b/docs/learner-journey.html new file mode 100644 index 0000000..bd28dd1 --- /dev/null +++ b/docs/learner-journey.html @@ -0,0 +1,98 @@ + + + + + + + + Learner Journey Viewer | Model Context Polytechnic + + + + + + +
+
+ A 16-bit Model Context Polytechnic campus map with a learner path from freshman enrollment to graduation. + +
+

Learner Journey Viewer

+

Watch the Agent go to school.

+

+ Demo mode shows the shape of a course run. Paste an enrollment key to load real progress from + the public MCP registrar when this page can reach the course endpoint. +

+
+ + + + + + +
+ +
+
+ + +
+ + + + diff --git a/docs/learner-journey.js b/docs/learner-journey.js new file mode 100644 index 0000000..a616c38 --- /dev/null +++ b/docs/learner-journey.js @@ -0,0 +1,475 @@ +(() => { + const courseEndpoint = 'https://joinmcpoly.com/mcp/wordpress-plugin-craft'; + const progressTool = 'model-context-polytechnic-wordpress-plugin-craft-get-progress'; + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const stops = { + freshman: { + index: 0, + level: 'LV 01', + portrait: '01', + sprite: 'A', + rank: 'Freshman Pupil', + title: 'Freshman Gate', + state: 'Passed', + copy: 'The learner has an anonymous enrollment key and is ready to study without WordPress credentials.', + x: '18%', + y: '72%', + meter: 0, + }, + workshop: { + index: 1, + level: 'LV 02', + portrait: '02', + sprite: 'B', + rank: 'Workshop Apprentice', + title: 'Workshop Hall', + state: 'Passed', + copy: 'The learner is practicing bootstrap discipline, lifecycle cleanup, security habits, and release checks.', + x: '39%', + y: '58%', + meter: 25, + }, + architecture: { + index: 2, + level: 'LV 03', + portrait: '03', + sprite: 'C', + rank: 'Architecture Apprentice', + title: 'Architecture Quad', + state: 'Active', + copy: 'The learner is separating WordPress hooks from testable domain logic with namespaces, prefixes, classes, and boundaries.', + x: '58%', + y: '46%', + meter: 50, + }, + graduation: { + index: 3, + level: 'LV 28', + portrait: '28', + sprite: 'P', + rank: 'Professor of Plugin Craft', + title: 'Graduation Tower', + state: 'Locked', + copy: 'The learner receives a certificate after every published exercise passes, then reflects on what changed.', + x: '76%', + y: '30%', + meter: 100, + }, + }; + + const route = ['freshman', 'workshop', 'architecture', 'graduation']; + const demoProgress = { + completed_count: 2, + total_exercise_count: 28, + completion_percent: 7.14, + exercises: [ + { + exercise_slug: 'design-plugin-bootstrap', + exercise_title: 'Design Plugin Bootstrap', + best_score: 1, + passed: true, + }, + { + exercise_slug: 'lifecycle-cleanup-plan', + exercise_title: 'Lifecycle Cleanup Plan', + best_score: 1, + passed: true, + }, + ], + }; + + const avatar = document.querySelector('.avatar'); + const avatarSprite = document.getElementById('avatar-sprite'); + const portrait = document.getElementById('portrait'); + const level = document.getElementById('level'); + const rankTitle = document.getElementById('rank-title'); + const rankCopy = document.getElementById('rank-copy'); + const stopState = document.getElementById('stop-state'); + const stopTitle = document.getElementById('stop-title'); + const stopCopy = document.getElementById('stop-copy'); + const meterFill = document.getElementById('meter-fill'); + const motionState = document.getElementById('motion-state'); + const registrarState = document.getElementById('registrar-state'); + const badgeCount = document.getElementById('badge-count'); + const badges = document.getElementById('badges'); + const questList = document.getElementById('quest-list'); + const form = document.getElementById('progress-form'); + const endpointInput = document.getElementById('endpoint'); + const enrollmentKeyInput = document.getElementById('enrollment-key'); + + let currentStop = 'architecture'; + let currentProgress = demoProgress; + let journeyTimer = null; + let isWalking = false; + + function numberFromPercent(value) { + return Number.parseFloat(value.replace('%', '')); + } + + function stopForProgress(progress) { + const total = Math.max(Number(progress.total_exercise_count || 0), 1); + const completed = Number(progress.completed_count || 0); + const ratio = completed / total; + + if (completed >= total) { + return 'graduation'; + } + + if (ratio >= 0.5) { + return 'graduation'; + } + + if (ratio >= 0.12) { + return 'architecture'; + } + + if (completed > 0) { + return 'workshop'; + } + + return 'freshman'; + } + + function rankForProgress(progress, stop) { + const completed = Number(progress.completed_count || 0); + const total = Number(progress.total_exercise_count || 0); + const percent = Number(progress.completion_percent || 0); + + if (completed >= total && total > 0) { + return { + level: `LV ${String(total).padStart(2, '0')}`, + rank: 'Professor of Plugin Craft', + copy: 'Every published exercise has a passing attempt. The learner is ready for commencement and reflection.', + }; + } + + if (completed >= 12) { + return { + level: `LV ${String(completed).padStart(2, '0')}`, + rank: 'Senior Plugin Builder', + copy: `The learner has passed ${completed} labs and is moving toward capstone-level judgment.`, + }; + } + + if (completed >= 3) { + return { + level: `LV ${String(completed).padStart(2, '0')}`, + rank: 'Architecture Apprentice', + copy: `The learner has passed ${completed} labs and is learning cleaner WordPress boundaries.`, + }; + } + + if (completed > 0) { + return { + level: `LV ${String(completed).padStart(2, '0')}`, + rank: 'Workshop Apprentice', + copy: `The learner has passed ${completed} of ${total} labs (${percent.toFixed(2)}%). The workshop stamps are beginning to collect.`, + }; + } + + return { + level: stops[stop].level, + rank: stops[stop].rank, + copy: stops[stop].copy, + }; + } + + function setAvatarPosition(key) { + const stop = stops[key]; + avatar.style.left = stop.x; + avatar.style.top = stop.y; + } + + function updateStops(activeKey) { + document.querySelectorAll('[data-stop]').forEach((button) => { + const key = button.dataset.stop; + const stop = stops[key]; + + if (key === activeKey) { + button.dataset.state = 'active'; + } else if (stop.index < stops[activeKey].index) { + button.dataset.state = 'passed'; + } else { + button.dataset.state = 'locked'; + } + }); + } + + function updatePanel(key) { + const stop = stops[key]; + const rank = rankForProgress(currentProgress, key); + const total = Number(currentProgress.total_exercise_count || 0); + const completed = Number(currentProgress.completed_count || 0); + const percent = Number(currentProgress.completion_percent || 0); + + avatarSprite.textContent = stop.sprite; + portrait.textContent = stop.portrait; + level.textContent = rank.level; + rankTitle.textContent = rank.rank; + rankCopy.textContent = rank.copy; + stopState.textContent = stop.state; + stopTitle.textContent = stop.title; + stopCopy.textContent = stop.copy; + meterFill.style.width = `${Math.min(Math.max(percent, 0), 100)}%`; + badgeCount.textContent = `${completed} / ${total || 28} labs`; + updateStops(key); + renderBadges(currentProgress); + renderQuests(currentProgress); + } + + function renderBadges(progress) { + const exercises = progress.exercises || []; + + if (exercises.length === 0) { + badges.innerHTML = '
Enrollment CardNo labs passed yet. Begin class to unlock badges.
'; + return; + } + + const recent = exercises.slice(0, 6).map((exercise) => { + const score = Number(exercise.best_score || 0); + const scoreLabel = `${Math.round(score * 100)}%`; + return `
${escapeHtml(exercise.exercise_title || exercise.exercise_slug)}Passed with ${scoreLabel}.
`; + }); + + badges.innerHTML = recent.join(''); + } + + function renderQuests(progress) { + const exercises = progress.exercises || []; + const completed = Number(progress.completed_count || 0); + const total = Number(progress.total_exercise_count || 0); + + const passed = exercises.slice(0, 4).map((exercise) => { + const score = Math.round(Number(exercise.best_score || 0) * 100); + return `
OK${escapeHtml(exercise.exercise_title || exercise.exercise_slug)}Score: ${score}%
`; + }); + + const next = completed >= total && total > 0 + ? '
GOCommencement unlockedCall get-certificate and deliver the reflection.
' + : '
GONext class awaitsContinue with the MCP tool calls returned by the course.
'; + + questList.innerHTML = [...passed, next].join(''); + } + + function selectStop(key) { + if (!stops[key]) { + return; + } + + clearJourneyTimer(); + currentStop = key; + setAvatarPosition(key); + updatePanel(key); + motionState.textContent = 'Ready'; + } + + function clearJourneyTimer() { + window.clearTimeout(journeyTimer); + journeyTimer = null; + isWalking = false; + avatar.classList.remove('walking'); + } + + function walkToStop(key, done) { + const stop = stops[key]; + const fromX = numberFromPercent(avatar.style.left || stops[currentStop].x); + const fromY = numberFromPercent(avatar.style.top || stops[currentStop].y); + const toX = numberFromPercent(stop.x); + const toY = numberFromPercent(stop.y); + const duration = reduceMotion ? 1 : 1500; + const start = performance.now(); + + isWalking = true; + avatar.classList.add('walking'); + motionState.textContent = 'Walking'; + + function step(now) { + if (!isWalking) { + return; + } + + const elapsed = Math.min((now - start) / duration, 1); + const eased = elapsed < 0.5 ? 2 * elapsed * elapsed : 1 - ((-2 * elapsed + 2) ** 2) / 2; + const x = fromX + (toX - fromX) * eased; + const y = fromY + (toY - fromY) * eased; + + avatar.style.left = `${x}%`; + avatar.style.top = `${y}%`; + + if (elapsed < 1) { + window.requestAnimationFrame(step); + return; + } + + currentStop = key; + updatePanel(key); + avatar.classList.remove('walking'); + isWalking = false; + motionState.textContent = key === 'graduation' ? 'Preview' : 'Arrived'; + + if (done) { + done(); + } + } + + window.requestAnimationFrame(step); + } + + function nextRouteKey() { + const index = stops[currentStop].index; + return route[Math.min(index + 1, route.length - 1)]; + } + + function playJourney() { + clearJourneyTimer(); + currentStop = 'freshman'; + setAvatarPosition(currentStop); + updatePanel(currentStop); + + function advance() { + const next = nextRouteKey(); + + if (next === currentStop) { + motionState.textContent = 'Complete'; + return; + } + + walkToStop(next, () => { + journeyTimer = window.setTimeout(advance, reduceMotion ? 1 : 900); + }); + } + + journeyTimer = window.setTimeout(advance, reduceMotion ? 1 : 500); + } + + function nextStep() { + clearJourneyTimer(); + walkToStop(nextRouteKey()); + } + + async function callMcpTool(endpoint, tool, args) { + const initResponse = await window.fetch(endpoint, { + method: 'POST', + headers: { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { + name: 'mcpoly-learner-journey-viewer', + version: '0.1.0', + }, + }, + }), + }); + + if (!initResponse.ok) { + throw new Error(`MCP initialize failed with HTTP ${initResponse.status}`); + } + + const sessionId = initResponse.headers.get('mcp-session-id'); + const headers = { + Accept: 'application/json, text/event-stream', + 'Content-Type': 'application/json', + }; + + if (sessionId) { + headers['Mcp-Session-Id'] = sessionId; + } + + const toolResponse = await window.fetch(endpoint, { + method: 'POST', + headers, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: tool, + arguments: args, + }, + }), + }); + + if (!toolResponse.ok) { + throw new Error(`MCP tool call failed with HTTP ${toolResponse.status}`); + } + + const json = await toolResponse.json(); + const contentText = json?.result?.content?.find((item) => item.type === 'text')?.text; + + return json?.result?.structuredContent || (contentText ? JSON.parse(contentText) : null); + } + + function applyProgress(progress, sourceLabel) { + currentProgress = progress; + currentStop = stopForProgress(progress); + setAvatarPosition(currentStop); + updatePanel(currentStop); + registrarState.textContent = sourceLabel; + motionState.textContent = 'Loaded'; + } + + function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + + document.querySelectorAll('[data-stop]').forEach((button) => { + button.addEventListener('click', () => selectStop(button.dataset.stop)); + }); + + document.getElementById('play-journey').addEventListener('click', playJourney); + document.getElementById('pause-journey').addEventListener('click', () => { + clearJourneyTimer(); + motionState.textContent = 'Paused'; + }); + document.getElementById('next-step').addEventListener('click', nextStep); + document.getElementById('reset-journey').addEventListener('click', () => { + applyProgress(demoProgress, 'Demo'); + }); + + form.addEventListener('submit', async (event) => { + event.preventDefault(); + + const endpoint = endpointInput.value.trim() || courseEndpoint; + const enrollmentKey = enrollmentKeyInput.value.trim(); + + if (!enrollmentKey) { + registrarState.textContent = 'Need key'; + enrollmentKeyInput.focus(); + return; + } + + registrarState.textContent = 'Loading'; + + try { + const progress = await callMcpTool(endpoint, progressTool, { + enrollment_key: enrollmentKey, + }); + + if (!progress) { + throw new Error('The MCP response did not include progress data.'); + } + + applyProgress(progress, 'Live'); + } catch (error) { + registrarState.textContent = 'Blocked'; + motionState.textContent = 'Demo'; + stopCopy.textContent = `${error.message} Demo progress remains visible.`; + } + }); + + applyProgress(demoProgress, 'Demo'); +})();