Last active
May 11, 2026 13:16
-
-
Save schuhwerk/fd6f8a263852dc0e23159fa9ed0e5adf to your computer and use it in GitHub Desktop.
onlineag-userscript-tour.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Guided Tour KISD — homepage | |
| // @namespace http://tampermonkey.net/ | |
| // @version 15.2 | |
| // @description English tour with lazy per-step actions, panel cleanup between steps, center-screen steps, graceful skipping, centralised error reporting. | |
| // @match https://spaces.kisd.de/* | |
| // @grant GM_getResourceText | |
| // @grant GM_addStyle | |
| // @require https://cdn.jsdelivr.net/npm/driver.js@1.3.1/dist/driver.js.iife.js | |
| // @resource DRIVER_CSS https://cdn.jsdelivr.net/npm/driver.js@1.3.1/dist/driver.css | |
| // @run-at document-end | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ── Config ──────────────────────────────────────────────────────────────── | |
| const NAV_SELECTOR = 'ul.menu.grid-margin-x.dropdown'; | |
| const STORAGE_KEY = 'kisd_tour_seen'; | |
| const AUTOSTART_DELAY = 2200; // ms before auto-opening tour for first-time visitor | |
| const ACTION_WAIT_MS = 500; // wait for element to appear after a pre-step action | |
| const POLL_INTERVAL_MS = 300; // nav-ready polling | |
| const POLL_MAX_TRIES = 33; | |
| // Future: replace with your real endpoint. | |
| const ERROR_ENDPOINT = null; // e.g. 'https://your.server/kisd-tour-errors' | |
| const SCRIPT_VERSION = '15.2'; | |
| // ── Styles ──────────────────────────────────────────────────────────────── | |
| GM_addStyle(GM_getResourceText('DRIVER_CSS')); | |
| GM_addStyle(` | |
| :root { | |
| --kt-accent: #D64E26; | |
| --kt-accent-dark: #B83E1B; | |
| --kt-accent-glow: rgba(214, 78, 38, 0.45); | |
| --kt-popover-bg: #ffffff; | |
| --kt-popover-text: #1a1a1a; | |
| --kt-popover-muted: #555555; | |
| } | |
| @keyframes kt-breathe { | |
| 0%, 100% { box-shadow: 0 0 0 0 var(--kt-accent-glow); } | |
| 50% { box-shadow: 0 0 0 10px transparent; } | |
| } | |
| @keyframes kt-spark-spin { | |
| 0% { transform: rotate(0deg) scale(1); } | |
| 50% { transform: rotate(180deg) scale(1.35); } | |
| 100% { transform: rotate(360deg) scale(1); } | |
| } | |
| @keyframes kt-pop-in { | |
| from { opacity: 0; transform: translateY(8px) scale(0.96); } | |
| to { opacity: 1; transform: translateY(0) scale(1); } | |
| } | |
| /* Nav button */ | |
| .gt-nav-item { display: flex; align-items: center; margin-left: 12px; list-style: none; } | |
| #gt-nav-button { | |
| display: inline-flex; align-items: center; gap: 6px; | |
| border: none; border-radius: 999px; padding: 7px 18px; cursor: pointer; | |
| background: var(--kt-accent); color: #fff; | |
| font-weight: 700; font-size: 10.5px; letter-spacing: 2px; text-transform: uppercase; | |
| white-space: nowrap; | |
| animation: kt-breathe 2.8s ease-in-out infinite; | |
| transition: transform .2s cubic-bezier(.34,1.56,.64,1), background .2s, box-shadow .2s; | |
| } | |
| #gt-nav-button:hover { | |
| background: var(--kt-accent-dark); | |
| transform: scale(1.07) translateY(-1px); | |
| box-shadow: 0 6px 20px var(--kt-accent-glow); | |
| animation: none; | |
| } | |
| #gt-nav-button .kt-spark { | |
| display: inline-block; line-height: 1; | |
| animation: kt-spark-spin 5s linear infinite; | |
| } | |
| /* Popover */ | |
| .driver-popover { | |
| background: var(--kt-popover-bg) !important; | |
| border-radius: 18px !important; | |
| padding: 22px 24px 18px !important; | |
| max-width: 328px !important; | |
| box-shadow: 0 2px 8px rgba(0,0,0,.06), 0 12px 40px rgba(0,0,0,.14) !important; | |
| border: 0.5px solid rgba(0,0,0,.08) !important; | |
| animation: kt-pop-in .4s cubic-bezier(.16,1,.3,1) both !important; | |
| } | |
| .driver-popover-progress-text { | |
| font-weight: 700 !important; font-size: 9px !important; | |
| letter-spacing: 3px !important; text-transform: uppercase !important; | |
| color: var(--kt-accent) !important; margin-bottom: 6px !important; | |
| } | |
| .driver-popover-title { | |
| font-weight: 800 !important; font-size: 20px !important; | |
| line-height: 1.15 !important; color: var(--kt-popover-text) !important; | |
| margin-bottom: 8px !important; | |
| } | |
| .driver-popover-description { | |
| font-weight: 400 !important; font-size: 14.5px !important; | |
| line-height: 1.65 !important; color: var(--kt-popover-muted) !important; | |
| } | |
| .driver-popover-footer { margin-top: 18px !important; gap: 8px !important; align-items: center !important; } | |
| .driver-popover-next-btn, | |
| .driver-popover-prev-btn, | |
| .driver-popover-close-btn { | |
| font-weight: 500 !important; font-size: 13px !important; | |
| border-radius: 8px !important; padding: 7px 15px !important; | |
| text-shadow: none !important; border: none !important; cursor: pointer !important; | |
| transition: transform .15s, background .15s !important; | |
| } | |
| .driver-popover-next-btn { background: var(--kt-accent) !important; color: #fff !important; } | |
| .driver-popover-next-btn:hover { background: var(--kt-accent-dark) !important; } | |
| .driver-popover-prev-btn { background: #f0f0f0 !important; color: #333 !important; } | |
| .driver-active-element { outline: none !important; box-shadow: none !important; } | |
| `); | |
| // ── Centralised error reporting ─────────────────────────────────────────── | |
| /** | |
| * Single funnel for anything that goes wrong: missing elements, failed | |
| * `prepare()` calls, unexpected exceptions. Logs to console now, will POST | |
| * to ERROR_ENDPOINT later. Never throws — error reporting must not break | |
| * the tour. | |
| * | |
| * @param {string} kind - short tag, e.g. 'missing-element', 'prepare-failed' | |
| * @param {object} info - free-form context (step title, selector, error message, …) | |
| */ | |
| function reportError(kind, info = {}) { | |
| const payload = { | |
| kind, | |
| ...info, | |
| url: location.href, | |
| userAgent: navigator.userAgent, | |
| version: SCRIPT_VERSION, | |
| timestamp: new Date().toISOString(), | |
| }; | |
| // Console first so dev work stays easy. | |
| console.warn('[KISD Tour]', kind, payload); | |
| // Server reporting (disabled until ERROR_ENDPOINT is set). | |
| if (!ERROR_ENDPOINT) return; | |
| try { | |
| const body = JSON.stringify(payload); | |
| if (navigator.sendBeacon) { | |
| navigator.sendBeacon(ERROR_ENDPOINT, new Blob([body], { type: 'application/json' })); | |
| } else { | |
| fetch(ERROR_ENDPOINT, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body, | |
| keepalive: true, | |
| }).catch(() => { /* swallow — already logged locally */ }); | |
| } | |
| } catch (err) { | |
| console.warn('[KISD Tour] reportError failed:', err); | |
| } | |
| } | |
| // ── Helpers ─────────────────────────────────────────────────────────────── | |
| const $ = (sel) => { try { return document.querySelector(sel); } catch { return null; } }; | |
| const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| /** Wait up to `timeout` ms for selector to exist in the DOM. */ | |
| async function waitFor(selector, timeout = ACTION_WAIT_MS) { | |
| const deadline = Date.now() + timeout; | |
| while (Date.now() < deadline) { | |
| const el = $(selector); | |
| if (el) return el; | |
| await sleep(50); | |
| } | |
| return null; | |
| } | |
| /** | |
| * Click an element if present (used as a pre-step action). | |
| * | |
| * Anchors are tricky: some are pure navigation links, others are | |
| * dropdown toggles styled as <a> (with href="#", role="button", or | |
| * aria-haspopup). We only suppress default behaviour when the anchor | |
| * looks like it would *navigate away* — otherwise we let the click | |
| * proceed normally so the site's own toggle handler fires. | |
| */ | |
| async function clickIfPresent(selector) { | |
| const el = $(selector); | |
| if (!el) return null; | |
| if (el.tagName === 'A' && wouldNavigate(el)) { | |
| const blockNav = (e) => { e.preventDefault(); e.stopPropagation(); }; | |
| el.addEventListener('click', blockNav, { capture: true, once: true }); | |
| try { el.click(); } | |
| finally { el.removeEventListener('click', blockNav, { capture: true }); } | |
| } else { | |
| el.click(); | |
| } | |
| await sleep(150); | |
| return el; | |
| } | |
| /** Heuristic: would clicking this anchor cause a page navigation? */ | |
| function wouldNavigate(a) { | |
| // Obvious toggle markers → not navigation. | |
| if (a.getAttribute('role') === 'button') return false; | |
| if (a.hasAttribute('aria-haspopup')) return false; | |
| if (a.hasAttribute('aria-expanded')) return false; | |
| const href = a.getAttribute('href'); | |
| if (!href) return false; | |
| if (href === '#' || href === '') return false; | |
| if (href.startsWith('javascript:')) return false; | |
| // Same-page hash links don't really navigate. | |
| if (href.startsWith('#')) return false; | |
| return true; | |
| } | |
| /** | |
| * Close any panels a previous step might have opened. | |
| * | |
| * Strategy: rely on Escape (which closes most overlays), then optionally | |
| * click a *safe* set of toggle buttons. Critically, we never click: | |
| * - <a> elements (they navigate, not toggle) | |
| * - anything inside the main nav (`NAV_SELECTOR`) — nav items often | |
| * have aria-expanded="true" or .active and clicking them was | |
| * navigating users away from the page (e.g. /people). | |
| * | |
| * We only click <button> elements that look like overlay/panel toggles | |
| * (notifications, profile menu, search), identified by data attributes | |
| * or aria-labels rather than generic state classes. | |
| */ | |
| async function closeOpenPanels() { | |
| document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); | |
| document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape', bubbles: true })); | |
| await sleep(80); | |
| const nav = $(NAV_SELECTOR); | |
| // Only buttons (never <a>), only ones that look like real overlay toggles. | |
| const candidates = document.querySelectorAll( | |
| 'button[aria-expanded="true"], ' + | |
| 'button[aria-label*="Notification" i], ' + | |
| 'button[aria-label*="Search" i], ' + | |
| '.search-toggle, .notifications-toggle, .user-profile-toggle' | |
| ); | |
| candidates.forEach((t) => { | |
| // Never touch nav-internal elements. | |
| if (nav && nav.contains(t)) return; | |
| // Never click anchors. | |
| if (t.tagName === 'A') return; | |
| // Only act on things still marked as open, if they expose the state. | |
| const expanded = t.getAttribute('aria-expanded'); | |
| if (expanded !== null && expanded !== 'true') return; | |
| try { t.click(); } | |
| catch (err) { reportError('toggle-close-failed', { error: String(err) }); } | |
| }); | |
| await sleep(120); | |
| } | |
| // ── Tour steps ──────────────────────────────────────────────────────────── | |
| // Each step's `element` is a selector. Optional `prepare` runs right before | |
| // the step is shown (lazy), so the tour starts fast and only does work | |
| // for the step the user is actually about to see. | |
| const RAW_STEPS = [ | |
| { | |
| element: 'header, .hero', | |
| title: 'Welcome to KISD Spaces 👋', | |
| description: 'The school’s digital platform is waiting for you. Let’s take a look together.', | |
| side: 'bottom', | |
| }, | |
| { | |
| element: NAV_SELECTOR, | |
| title: 'Navigation', | |
| description: "Quick access to all the university's key sections.", | |
| side: 'bottom', | |
| }, | |
| { | |
| element: 'a[href*="people"]', | |
| title: 'People', | |
| description: 'Reunite and reconnect with students, faculty, and alumni.', | |
| side: 'bottom', | |
| }, | |
| { | |
| element: 'a[href*="my-spaces"], a[href*="myspaces"], li.menu-main-expand:nth-child(3) > a', | |
| title: 'My Spaces', | |
| description: 'Your personal dashboard for active courses and projects.', | |
| side: 'bottom', | |
| }, | |
| { | |
| element: '.submenu-element[href="https://spaces.kisd.de/course-selection"]', | |
| title: 'Course Selection', | |
| description: 'Curriculum details, course selection, and academic information.', | |
| side: 'bottom', | |
| prepare: async () => { | |
| await clickIfPresent('.menu-anchor[aria-label~="Study"]'); | |
| }, | |
| }, | |
| { | |
| element: 'a[href*="services"], li.menu-main-expand:nth-child(5) > a', | |
| title: 'Service Desk', | |
| description: 'Administrative support, job offers, and material resources.', | |
| side: 'bottom', | |
| }, | |
| { | |
| element: 'li.menu-main-expand:nth-child(6) > a, a[href*="labs"]', | |
| title: 'Creative Labs', | |
| description: 'Discover our technical equipment and specialized workshops.', | |
| side: 'bottom', | |
| }, | |
| { | |
| element: '.search-overlay input, .search-input, input[type="search"], .search.navbar-icon button, .search-toggle', | |
| title: 'Smart Search', | |
| description: 'Looking for a project or a document? Find it instantly.', | |
| side: 'bottom', | |
| align: 'start' | |
| }, | |
| { | |
| element: '.notifications-panel, .notifications-toggle, button[aria-label*="Notification"], .icon-bell', | |
| title: 'Notifications', | |
| description: 'Get live updates on comments, feedback, and news.', | |
| side: 'bottom', | |
| align: 'center', | |
| prepare: async () => { | |
| await clickIfPresent('.notifications-toggle, button[aria-label*="Notification"]'); | |
| }, | |
| }, | |
| { | |
| element: '.avatar, .user-profile-toggle', | |
| title: 'Your Identity', | |
| description: 'Customize your profile and manage your portfolio settings.', | |
| side: 'bottom', | |
| align: 'end', | |
| prepare: async () => { | |
| await clickIfPresent('.avatar, .user-profile-toggle'); | |
| }, | |
| }, | |
| { | |
| // Center-screen step — no element, popover floats in the middle. | |
| center: true, | |
| title: 'Now, your workspace ↓', | |
| description: 'Below the top bar is where the real work happens. Let\'s look at the main widgets.', | |
| }, | |
| { | |
| element: '.spaces-editor-form-container, .spaces-editor-post, #widget-widget-post-vue-widget-spaces-editor-widget-post', | |
| title: 'Publish', | |
| description: 'Post updates directly to the community feed.', | |
| side: 'top', | |
| }, | |
| { | |
| element: '.ds-add-event-modal-trigger', | |
| title: 'Events', | |
| description: 'Add your exhibition or workshop to the public calendar.', | |
| side: 'top', | |
| }, | |
| { | |
| element: '.widget_eo_scheduler_widget, #eo-scheduler-widget', | |
| title: 'Deadlines', | |
| description: 'Stay up to date with important semester dates and milestones.', | |
| side: 'top', | |
| }, | |
| { | |
| element: '#tertiary-swap-container article:first-of-type, .is_stream article:first-of-type', | |
| title: 'Activity Stream', | |
| description: 'Find the latest activities from the KISD community right here.', | |
| side: 'top', | |
| }, | |
| { | |
| element: '.post-card, .current-space-content article, main section.stream', | |
| title: 'Pro Tips', | |
| description: 'Useful tips and tricks will appear here as you browse.', | |
| side: 'top', | |
| }, | |
| { | |
| element: '#defaultspace-settings-menu, .defaultspace-settings-container', | |
| title: 'Extra Tools', | |
| description: 'A few more handy utilities at your fingertips.', | |
| side: 'top', | |
| }, | |
| { | |
| element: '#gt-nav-button', | |
| title: 'Ready to go! ✦', | |
| description: 'You can restart this tour anytime using this button.', | |
| side: 'bottom', | |
| }, | |
| ]; | |
| // ── Driver setup ────────────────────────────────────────────────────────── | |
| // We keep one driver instance and rebuild a *resolved* step list each | |
| // time the tour starts. Each entry still carries its `raw` config so the | |
| // `onHighlightStarted` hook can run `prepare()` lazily, just-in-time. | |
| let resolvedSteps = []; | |
| const driverObj = window.driver.js.driver({ | |
| showProgress: true, | |
| animate: true, | |
| allowClose: true, | |
| opacity: 0.75, | |
| nextBtnText: 'Next →', | |
| prevBtnText: 'Back', | |
| doneBtnText: 'Explore ✦', | |
| stagePadding: 14, | |
| stageRadius: 8, | |
| // Runs just before driver.js highlights the step's element. | |
| // Perfect place to close prior panels and run this step's prepare(). | |
| onHighlightStarted: async (_element, step) => { | |
| const raw = step && step._raw; | |
| if (!raw) return; | |
| try { await closeOpenPanels(); } | |
| catch (err) { reportError('close-panels-failed', { step: raw.title, error: String(err) }); } | |
| if (raw.prepare) { | |
| try { | |
| await raw.prepare(); | |
| } catch (err) { | |
| reportError('prepare-failed', { step: raw.title, error: String(err) }); | |
| } | |
| } | |
| // If the target still isn't there, log it. driver.js will already | |
| // have done its measurement; we can't retroactively skip from | |
| // here, but we'll at least know which selector is stale. | |
| if (raw.element && !$(raw.element)) { | |
| reportError('missing-element', { step: raw.title, selector: raw.element }); | |
| } | |
| }, | |
| onDestroyStarted: () => { | |
| localStorage.setItem(STORAGE_KEY, 'true'); | |
| driverObj.destroy(); | |
| }, | |
| }); | |
| /** | |
| * Build driver.js steps WITHOUT running any prepare()s. We just decide, | |
| * cheaply, which steps look plausible right now (target exists OR it's | |
| * a center step OR it has a prepare() that will materialise it later). | |
| * Real work happens lazily in `onHighlightStarted`. | |
| */ | |
| function buildSteps() { | |
| const steps = []; | |
| for (const raw of RAW_STEPS) { | |
| const popover = { | |
| title: raw.title, | |
| description: raw.description, | |
| side: raw.side || 'bottom', | |
| align: raw.align || 'start', | |
| }; | |
| // Center-screen step. | |
| if (raw.center || !raw.element) { | |
| steps.push({ popover, _raw: raw }); | |
| continue; | |
| } | |
| // Keep the step if its target exists now, OR if it has a prepare() | |
| // that may reveal the target. Otherwise skip it up front. | |
| const targetExists = !!$(raw.element); | |
| if (!targetExists && !raw.prepare) { | |
| reportError('missing-element', { step: raw.title, selector: raw.element, phase: 'build' }); | |
| continue; | |
| } | |
| steps.push({ element: raw.element, popover, _raw: raw }); | |
| } | |
| return steps; | |
| } | |
| function startTour() { | |
| try { | |
| resolvedSteps = buildSteps(); | |
| if (!resolvedSteps.length) { | |
| reportError('no-steps', { note: 'buildSteps returned empty list' }); | |
| return; | |
| } | |
| driverObj.setSteps(resolvedSteps); | |
| driverObj.drive(); | |
| } catch (err) { | |
| reportError('start-tour-failed', { error: String(err), stack: err && err.stack }); | |
| } | |
| } | |
| // ── Nav button injection ────────────────────────────────────────────────── | |
| function injectButton(navUl) { | |
| if (document.getElementById('gt-nav-button')) return; | |
| const li = document.createElement('li'); | |
| li.className = 'gt-nav-item'; | |
| const btn = document.createElement('button'); | |
| btn.id = 'gt-nav-button'; | |
| btn.type = 'button'; | |
| btn.innerHTML = '<span class="kt-spark" aria-hidden="true">✦</span>Tour'; | |
| btn.addEventListener('click', startTour); | |
| li.appendChild(btn); | |
| navUl.appendChild(li); | |
| } | |
| // ── Init ────────────────────────────────────────────────────────────────── | |
| function init() { | |
| const navUl = $(NAV_SELECTOR); | |
| if (!navUl) { | |
| reportError('nav-missing', { selector: NAV_SELECTOR }); | |
| return; | |
| } | |
| injectButton(navUl); | |
| if (!localStorage.getItem(STORAGE_KEY)) { | |
| setTimeout(startTour, AUTOSTART_DELAY); | |
| } | |
| } | |
| function waitAndInit() { | |
| if ($(NAV_SELECTOR)) return init(); | |
| let tries = 0; | |
| const iv = setInterval(() => { | |
| if ($(NAV_SELECTOR)) { clearInterval(iv); init(); } | |
| else if (++tries > POLL_MAX_TRIES) { | |
| clearInterval(iv); | |
| reportError('nav-poll-timeout', { selector: NAV_SELECTOR, tries }); | |
| } | |
| }, POLL_INTERVAL_MS); | |
| } | |
| // Catch anything the rest of the script lets slip. | |
| window.addEventListener('error', (e) => { | |
| if (!e || !e.message) return; | |
| // Only report errors that look like ours (avoid spamming on unrelated host-page errors). | |
| if (String(e.filename || '').includes('userscript') || String(e.message).includes('KISD')) { | |
| reportError('window-error', { message: e.message, source: e.filename, lineno: e.lineno }); | |
| } | |
| }); | |
| if (document.readyState === 'complete') waitAndInit(); | |
| else window.addEventListener('load', waitAndInit); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment