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
+
+
+
+
+
Skip to learner status
+
+
+
+
+
+
+
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.
+
+
+
+ 01
+ 02
+ 03
+ 04
+
+
+ A
+
+
+
+
+
+
+
+
+
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 Card No 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
+ ? '
GO Commencement unlockedCall get-certificate and deliver the reflection.
'
+ : '
GO Next 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');
+})();