From 594af113b396bf5b105fdd11c040ffb73f8efeae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Umut=20Cans=C4=B1n=20Torgayl=C4=B1?= Date: Sun, 26 Jul 2026 18:39:29 +0300 Subject: [PATCH] refactor(tour): promote shared tour-core.js helper scaffolding --- static/index.html | 1 + static/js/tour-core.js | 158 +++++++++++++++++++++++++++++++++++++ tests/test_tour_core_js.py | 34 ++++++++ 3 files changed, 193 insertions(+) create mode 100644 static/js/tour-core.js create mode 100644 tests/test_tour_core_js.py 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