Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active May 11, 2026 13:16
Show Gist options
  • Select an option

  • Save schuhwerk/fd6f8a263852dc0e23159fa9ed0e5adf to your computer and use it in GitHub Desktop.

Select an option

Save schuhwerk/fd6f8a263852dc0e23159fa9ed0e5adf to your computer and use it in GitHub Desktop.
onlineag-userscript-tour.js
// ==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