diff --git a/static/index.html b/static/index.html
index 8257660fe..a40187dc2 100644
--- a/static/index.html
+++ b/static/index.html
@@ -2507,6 +2507,7 @@
+
diff --git a/static/js/tour-core.js b/static/js/tour-core.js
new file mode 100644
index 000000000..e76e1e5df
--- /dev/null
+++ b/static/js/tour-core.js
@@ -0,0 +1,158 @@
+// static/js/tour-core.js
+// Shared onboarding tour helper & scaffolding system for Odysseus.
+
+(function (window) {
+ 'use strict';
+
+ function ensureTourStyles() {
+ if (document.getElementById('tour-styles')) return;
+ const s = document.createElement('style');
+ s.id = 'tour-styles';
+ s.textContent = `
+ #tour-tooltip{position:fixed;z-index:10001;background:var(--bg);color:var(--fg);
+ border:1px solid var(--border);border-radius:8px;padding:12px 14px;max-width:280px;
+ font-family:inherit;font-size:0.8rem;line-height:1.5;
+ box-shadow:0 2px 12px rgba(0,0,0,0.3);pointer-events:auto;
+ opacity:0;transform:translateY(4px);transition:opacity 0.3s ease-out,transform 0.3s ease-out}
+ #tour-tooltip.tour-fade-in{opacity:1;transform:translateY(0)}
+ #tour-tooltip .tour-text{margin-bottom:8px;opacity:0.8}
+ .tour-arrow{position:absolute;width:10px;height:10px;background:var(--bg);
+ border:1px solid var(--border);transform:rotate(45deg);pointer-events:none}
+ .tour-nav{display:flex;align-items:center;justify-content:space-between}
+ .tour-nav button{background:none;border:1px solid var(--border);color:var(--fg);
+ cursor:pointer;font-family:inherit;border-radius:4px;transition:all .1s}
+ .tour-nav button:hover{background:color-mix(in srgb,var(--fg) 8%,transparent)}
+ .tour-nav button:active{background:color-mix(in srgb,var(--fg) 16%,transparent);transform:scale(0.95)}
+ .tour-btn-arrow{font-size:1rem;padding:4px 12px;opacity:0.6}
+ .tour-btn-arrow:hover{opacity:1}
+ .tour-btn-arrow.disabled{opacity:0.15;pointer-events:none}
+ .tour-btn-skip{font-size:0.72rem;padding:3px 10px;opacity:0.35;border-color:transparent!important}
+ .tour-btn-skip:hover{opacity:0.6}
+ .tour-btn-arrow-pulse{opacity:1;border-color:var(--accent,var(--red));color:var(--accent,var(--red));
+ animation:tour-arrow-pulse 1.2s ease-in-out infinite}
+ @keyframes tour-arrow-pulse{
+ 0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--accent,var(--red)) 50%,transparent)}
+ 50% {box-shadow:0 0 0 6px color-mix(in srgb,var(--accent,var(--red)) 0%,transparent)}
+ }
+ `;
+ document.head.appendChild(s);
+ }
+
+ function cancelActiveTour(reason) {
+ document.querySelectorAll('.odysseus-highlight, .odysseus-highlight-click')
+ .forEach(e => e.classList.remove('odysseus-highlight', 'odysseus-highlight-click'));
+ document.querySelectorAll('.tour-halo').forEach(e => e.remove());
+ document.getElementById('tour-tooltip')?.remove();
+ document.body?.classList.remove('tour-active');
+ }
+
+ function makeHalo(target) {
+ const halo = document.createElement('div');
+ halo.className = 'tour-halo';
+ document.body.appendChild(halo);
+ const update = () => {
+ if (!target || !document.body.contains(target)) return;
+ const r = target.getBoundingClientRect();
+ halo.style.top = (r.top - 4) + 'px';
+ halo.style.left = (r.left - 4) + 'px';
+ halo.style.width = (r.width + 8) + 'px';
+ halo.style.height = (r.height + 8) + 'px';
+ };
+ update();
+ window.addEventListener('resize', update);
+ window.addEventListener('scroll', update, true);
+ requestAnimationFrame(() => halo.classList.add('tour-fade-in'));
+ return {
+ el: halo,
+ update,
+ destroy() {
+ window.removeEventListener('resize', update);
+ window.removeEventListener('scroll', update, true);
+ halo.remove();
+ },
+ };
+ }
+
+ function positionTooltip(tooltip, target) {
+ if (!tooltip || !target) return;
+ tooltip.querySelector('.tour-arrow')?.remove();
+ const r = target.getBoundingClientRect();
+ const ttW = 280;
+ tooltip.style.visibility = 'hidden';
+ tooltip.style.display = '';
+ const ttH = tooltip.offsetHeight || 100;
+
+ const arrow = document.createElement('div');
+ arrow.className = 'tour-arrow';
+
+ const gap = 12;
+ let top, left, arrowSide;
+
+ if (r.bottom + gap + ttH < window.innerHeight - 10) {
+ top = r.bottom + gap;
+ left = r.left + r.width / 2 - ttW / 2;
+ arrowSide = 'top';
+ } else if (r.top - gap - ttH > 10) {
+ top = r.top - gap - ttH;
+ left = r.left + r.width / 2 - ttW / 2;
+ arrowSide = 'bottom';
+ } else {
+ top = r.top + r.height / 2 - ttH / 2;
+ left = r.right + gap;
+ arrowSide = 'left';
+ }
+
+ if (left + ttW > window.innerWidth - 10) left = window.innerWidth - ttW - 10;
+ if (left < 10) left = 10;
+ if (top < 10) top = 10;
+
+ tooltip.style.top = top + 'px';
+ tooltip.style.left = left + 'px';
+
+ if (arrowSide === 'top') {
+ arrow.style.cssText = `top:-6px;left:${Math.min(Math.max(r.left + r.width / 2 - left - 5, 10), ttW - 20)}px;border-right:none;border-bottom:none`;
+ } else if (arrowSide === 'bottom') {
+ arrow.style.cssText = `bottom:-6px;left:${Math.min(Math.max(r.left + r.width / 2 - left - 5, 10), ttW - 20)}px;border-left:none;border-top:none`;
+ } else {
+ arrow.style.cssText = `left:-6px;top:${Math.min(Math.max(r.top + r.height / 2 - top - 5, 10), ttH - 20)}px;border-right:none;border-top:none`;
+ }
+ tooltip.appendChild(arrow);
+ tooltip.style.visibility = '';
+ }
+
+ function streamHTML(el, html, speedMs = 14) {
+ if (!el) return { cancel() {} };
+ el.innerHTML = '';
+ let i = 0, out = '';
+ let timer = setInterval(() => {
+ if (i >= html.length) { clearInterval(timer); timer = null; return; }
+ if (html[i] === '<') {
+ const end = html.indexOf('>', i);
+ if (end === -1) { out += html.slice(i); i = html.length; }
+ else { out += html.slice(i, end + 1); i = end + 1; }
+ } else {
+ out += html[i];
+ i++;
+ }
+ el.innerHTML = out;
+ }, speedMs);
+ return { cancel: () => { if (timer) { clearInterval(timer); el.innerHTML = html; } } };
+ }
+
+ function isTourActive() {
+ return document.body.classList.contains('tour-active');
+ }
+
+ const TourCore = {
+ ensureTourStyles,
+ cancelActiveTour,
+ makeHalo,
+ positionTooltip,
+ streamHTML,
+ isTourActive,
+ };
+
+ window.TourCore = TourCore;
+ window.cancelActiveTour = cancelActiveTour;
+
+})(typeof window !== 'undefined' ? window : this);
diff --git a/tests/test_tour_core_js.py b/tests/test_tour_core_js.py
new file mode 100644
index 000000000..662c25ded
--- /dev/null
+++ b/tests/test_tour_core_js.py
@@ -0,0 +1,34 @@
+# tests/test_tour_core_js.py
+"""Tests for static/js/tour-core.js module presence and JS syntax."""
+
+from pathlib import Path
+import subprocess
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+TOUR_CORE_JS = REPO_ROOT / "static" / "js" / "tour-core.js"
+
+
+def test_tour_core_file_exists():
+ """Verify that static/js/tour-core.js exists in the workspace."""
+ assert TOUR_CORE_JS.exists(), "tour-core.js must exist under static/js/"
+
+
+def test_tour_core_js_syntax():
+ """Verify that static/js/tour-core.js passes node syntax checking."""
+ result = subprocess.run(
+ ["node", "--check", str(TOUR_CORE_JS)],
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 0, f"node --check failed for tour-core.js:\n{result.stderr}"
+
+
+def test_tour_core_defines_global():
+ """Verify that tour-core.js exports window.TourCore."""
+ content = TOUR_CORE_JS.read_text(encoding="utf-8")
+ assert "window.TourCore" in content
+ assert "ensureTourStyles" in content
+ assert "cancelActiveTour" in content
+ assert "makeHalo" in content
+ assert "positionTooltip" in content
+ assert "streamHTML" in content