Skip to content

Instantly share code, notes, and snippets.

@schuhwerk
Last active June 1, 2026 14:47
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);
})();
@lea-tramati

Copy link
Copy Markdown

Hi,

So this is the code for the extension of KISD SPACE

"// ==UserScript==
// @name Guided Tour KISD — homepage
// @namespace http://tampermonkey.net/
// @Version 17.0
// @description Two-part tour: essential basics then optional deep-dive. Lazy per-step actions, panel cleanup, 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;

const ERROR_ENDPOINT   = null;   // e.g. 'https://your.server/kisd-tour-errors'
const SCRIPT_VERSION   = '17.0';

// ── Tour state ────────────────────────────────────────────────────────────
// Selector of the nav anchor that was clicked open by the current step's prepare().
// closeOpenPanels() uses it to toggle the menu closed before the next step.
let openedMenuAnchorSelector = null;
// {li, ul} for the Study dropdown opened manually — reset on next step.
let openedFoundationSubmenu = null;
// Prevents the re-entrant onDestroyStarted fire when we call driverObj.destroy() inside it.
let _tourDestroying = false;

// ── Keyboard navigation ───────────────────────────────────────────────────
function _onKeyNav(e) {
    if (!document.querySelector('.driver-popover')) return;
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
        e.preventDefault();
        e.stopPropagation();
        document.querySelector('.driver-popover-next-btn')?.click();
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
        e.preventDefault();
        e.stopPropagation();
        document.querySelector('.driver-popover-prev-btn')?.click();
    }
}

// ── Scroll block during tour ──────────────────────────────────────────────
function _onWheelBlock(e) {
    if (document.querySelector('.driver-popover')) {
        e.preventDefault();
        e.stopPropagation();
    }
}

// ── 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; }
    /* Fix: prevent the highlight cutout from sliding in from the top-left corner */
    .driver-overlay, .driver-overlay * { transition: none !important; }
    /* Credits shown at the bottom of final popovers */
    .kt-credits {
        display: block; margin-top: 12px; padding-top: 10px;
        border-top: 1px solid rgba(0,0,0,.07);
        font-size: 11px; color: #aaa; font-style: italic;
    }
    .kt-credits-link { color: var(--kt-accent); text-decoration: none; }
    .kt-credits-link:hover { text-decoration: underline; }
    /* Inline hint/note block inside popover descriptions */
    .kt-hint {
        display: block; margin-top: 10px; padding: 8px 12px;
        background: rgba(214,78,38,.06);
        border-left: 3px solid var(--kt-accent);
        border-radius: 0 6px 6px 0;
        font-size: 13px; color: #666; line-height: 1.5;
    }
    /* Pulse on the "Create" button inside My Spaces dropdown */
    @keyframes kt-create-pulse {
        0%, 100% { box-shadow: 0 0 0 0 rgba(40,167,69,.55); }
        50%       { box-shadow: 0 0 0 7px transparent; }
    }
    ._kt-create-highlight {
        animation: kt-create-pulse 1.4s ease-in-out infinite !important;
        border-radius: 4px !important;
        position: relative !important;
        z-index: 1 !important;
    }
    /* Expandable "What is a lab?" toggle inside the Labs popover */
    .kt-lab-toggle {
        display: block; margin-top: 8px;
        font-size: 13px; color: var(--kt-accent); cursor: pointer;
        user-select: none;
    }
    .kt-lab-toggle:hover { text-decoration: underline; }
    .kt-lab-expand {
        display: none; margin-top: 6px; padding: 8px 12px;
        background: rgba(214,78,38,.04);
        border-left: 3px solid var(--kt-accent);
        border-radius: 0 6px 6px 0;
        font-size: 13px; color: #666; line-height: 1.5;
    }
    /* "Go deeper" button rendered inside the last essential-tour popover */
    .kt-go-deeper-btn {
        display: inline-flex; align-items: center; gap: 6px;
        margin-top: 14px; padding: 8px 16px;
        background: var(--kt-accent); color: #fff;
        border: none; border-radius: 8px;
        font-weight: 700; font-size: 13px; cursor: pointer;
        transition: background .15s, transform .15s;
    }
    .kt-go-deeper-btn:hover { background: var(--kt-accent-dark); transform: translateY(-1px); }
    .kt-explore-btn {
        display: inline-flex; align-items: center; gap: 6px;
        margin-top: 14px; padding: 8px 16px;
        background: var(--kt-accent); color: #fff;
        border: none; border-radius: 8px;
        font-weight: 700; font-size: 13px; cursor: pointer;
        transition: background .15s, transform .15s;
    }
    .kt-explore-btn:hover { background: var(--kt-accent-dark); transform: translateY(-1px); }
    /* Study step: force popover position via CSS vars (beats FloatingUI inline styles) */
    .kt-study-popover {
        position: fixed !important;
        top:      var(--kt-study-top,  80px) !important;
        left:     var(--kt-study-left, 80px) !important;
        transform: none !important;
        width: 320px !important;
        max-width: 320px !important;
    }
    .kt-study-popover .driver-popover-arrow { display: none !important; }
    /* Force CSS hover dropdown open for nav items that use :hover (not JS click) */
    ._kt-hover-step > ul,
    ._kt-hover-step > .submenu,
    ._kt-hover-step > .dropdown-menu,
    ._kt-hover-step > [class*="submenu"],
    ._kt-hover-step > [class*="dropdown"] {
        display: block !important;
        visibility: visible !important;
        opacity: 1 !important;
        pointer-events: auto !important;
    }
`);

// ── Centralised error reporting ───────────────────────────────────────────
function reportError(kind, info = {}) {
    const payload = {
        kind,
        ...info,
        url:        location.href,
        userAgent:  navigator.userAgent,
        version:    SCRIPT_VERSION,
        timestamp:  new Date().toISOString(),
    };
    console.warn('[KISD Tour]', kind, payload);
    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(() => {});
        }
    } 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));

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;
}

async function clickIfPresent(selector) {
    const el = $(selector);
    if (!el) return null;
    if (el.tagName === 'A' && wouldNavigate(el)) {
        const blockNav = (e) => e.preventDefault();
        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;
}

function wouldNavigate(a) {
    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;
    if (href.startsWith('#'))                  return false;
    return true;
}

async function closeOpenPanels() {
    if (openedMenuAnchorSelector) {
        const sel = openedMenuAnchorSelector;
        openedMenuAnchorSelector = null;
        await clickIfPresent(sel);
    }

    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);
    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) => {
        if (nav && nav.contains(t)) return;
        if (t.tagName === 'A') return;
        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 ────────────────────────────────────────────────────────────
//
// PART 1 — Essential
// The must-knows for every new student: spaces, courses, labs,
// notifications, deadlines, and the community feed.
//
const ESSENTIAL_STEPS = [
    {
        center: true,
        title: 'Welcome to KISD Spaces 👋',
        description: "The school's platform, let's walk through the essentials together. We'll start with the key navigation buttons at the top of the page.",
    },
    {
        element: 'li.menu-main-expand:nth-child(3) > a',
        title: 'My Spaces',
        description: 'Your personal space where you can access and select your active courses, projects, and every space you belong to, all in one place.<span class="kt-hint">→ You can also create your own Space with the green <b>Create</b> button in this menu.</span>',
        side: 'bottom',
        align: 'start',
        prepare: async () => {
            await clickIfPresent('li.menu-main-expand:nth-child(3) > a');
            openedMenuAnchorSelector = 'li.menu-main-expand:nth-child(3) > a';
            const mySpacesLi = document.querySelector('li.menu-main-expand:nth-child(3)');
            const createBtn = mySpacesLi && (
                mySpacesLi.querySelector('a[href*="create"]') ||
                mySpacesLi.querySelector('a[href*="space/create"]') ||
                mySpacesLi.querySelector('[class*="create"]')
            );
            if (createBtn) createBtn.classList.add('_kt-create-highlight');
        },
    },
    {
        element: '._kt-course-sel',
        title: 'Course Selection',
        description: 'In <b>Course Selection</b>, browse the curriculum, pick your courses, and access other academic resources in the "Study" category.', 
        side: 'right',
        align: 'start',
        popoverClass: 'kt-study-popover',
        prepare: async () => {
            document.querySelectorAll('._kt-course-sel').forEach(el => el.classList.remove('_kt-course-sel'));
            const li = document.querySelector(NAV_SELECTOR + ' > li:nth-child(4)');
            if (!li) return;
            const anchor = li.querySelector(':scope > a');
            if (anchor) {
                anchor.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
                anchor.dispatchEvent(new MouseEvent('mouseover',  { bubbles: true }));
            }
            await clickIfPresent(NAV_SELECTOR + ' > li:nth-child(4) > a');
            openedMenuAnchorSelector = NAV_SELECTOR + ' > li:nth-child(4) > a';
            await sleep(300);
            const submenu = li.querySelector('ul[data-submenu]');
            if (!submenu) return;
            // If the dropdown didn't open via click (hover-only), fall back to manual positioning
            if (!submenu.getBoundingClientRect().height) {
                const rect = li.getBoundingClientRect();
                submenu.classList.add('js-dropdown-active');
                submenu.style.setProperty('position', 'fixed',            'important');
                submenu.style.setProperty('top',      rect.bottom + 'px', 'important');
                submenu.style.setProperty('left',     rect.left   + 'px', 'important');
                submenu.style.setProperty('display',  'block',            'important');
                submenu.style.setProperty('z-index',  '100001',           'important');
                openedFoundationSubmenu = { ul: submenu };
                await sleep(100);
            }
            // Mark the course-selection link for highlighting
            const courseLink =
                submenu.querySelector('a[href*="course-selection"]') ||
                submenu.querySelector('a[href*="course_selection"]') ||
                submenu.querySelector('a[href*="course"]')           ||
                submenu.querySelector('li > a');
            if (courseLink) courseLink.classList.add('_kt-course-sel');
            const sr = submenu.getBoundingClientRect();
            document.documentElement.style.setProperty('--kt-study-top',  sr.top  + 'px');
            document.documentElement.style.setProperty('--kt-study-left', (sr.right + 16) + 'px');
        },
    },
    {
        element: 'li.menu-main-expand:nth-child(6) > a',
        title: 'Labs',
        description: 'Explore the school\'s specialized labs: <b>Woodworkshop</b>, <b>FoodLab</b>, <b>C-lab</b>, and more, and access their technical resources.<span  class="kt-lab-toggle">▸ What is a lab?</span><span class="kt-lab-expand">A lab is a shared creative workspace dedicated to a specific craft or discipline, a place where students work on hands-on projects, access specialized equipment, and collaborate with peers.</span>',
        side: 'bottom',
        align: 'start',
        prepare: async () => {
            await clickIfPresent('li.menu-main-expand:nth-child(6) > a');
            openedMenuAnchorSelector = 'li.menu-main-expand:nth-child(6) > a';
            await sleep(250);
            // Close any space window / modal that auto-opened (e.g. C-lab panel)
            const autoPanel = $(
                '#globalModal .close, ' +
                '.modal.in .close, ' +
                '.modal.show .btn-close, ' +
                '.ui-dialog .ui-dialog-titlebar-close, ' +
                '.humhub-ui-modal .close'
            );
            if (autoPanel) autoPanel.click();
            const labsLi = document.querySelector('li.menu-main-expand:nth-child(6)');
            const labsSubmenu = labsLi && labsLi.querySelector('ul');
            if (labsSubmenu) {
                labsSubmenu.addEventListener('click', (e) => {
                    if (e.target.closest('a')) driverObj.moveNext();
                }, { once: true });
            }
        },
    },
    {
        element: '.notifications-toggle, button[aria-label*="Notification" i], .icon-bell',
        title: 'Notifications 🔔',
        description: 'Click here to see comments, feedback, and all the announcements.',
        side: 'bottom',
        align: 'end',
    },
    {
        element: '.widget_eo_scheduler_widget, #eo-scheduler-widget',
        title: 'Calendar',
        description: 'Semester dates, submission milestones, and key dates.',
        side: 'top',
    },
    {
        element: '#tertiary-swap-container, .is_stream',
        title: 'Posts & News',
        description: 'This is the <b>only section</b> on KISD Spaces where you can post directly: announcements, project updates, and everything happening at KISD.',
        side: 'top',
    },
    {
        element: '#gt-nav-button',
        title: "You're all set for now",
        description: 'You now know the essentials. You can restart this tour anytime.<br><br><button class="kt-go-deeper-btn">Go deeper &#8594;</button><br><br><span class="kt-credits">by the Online Ag &nbsp;·&nbsp; <a href="mailto:tramatilea@gmail.com" class="kt-credits-link">Contact</a></span>',
        side: 'bottom',
        doneBtnText: 'Done',
        popoverClass: 'kt-essential-end',
    },
];

// PART 2 — Extended (optional)
// For the curious: posting, people, services, search, profile,
// events, featured content, and power-user tools.
//
const EXTENDED_STEPS = [
    {
        center: true,
        title: "Let's go further",
        description: "You've got the basics down. Now let's explore the others features that can be helpful as you get more comfortable with the platform. Don't worry, you can always revisit this tour or skip steps as you like.",
    },
    {
        element: 'a[href*="people"]',
        title: 'People',
        description: 'Find students, professors, and alumni if you need to contact them or to see their profiles.',
        side: 'bottom',
    },
    {
        element: 'li.menu-main-expand:nth-child(5) > a',
        title: 'Services',
        description: 'Support, job offers, resources and more utilities.',
        side: 'bottom',
        align: 'start',
        prepare: async () => {
            await clickIfPresent('li.menu-main-expand:nth-child(5) > a');
            openedMenuAnchorSelector = 'li.menu-main-expand:nth-child(5) > a';
        },
    },
    {
        element: '.search-overlay input, .search-input, input[type="search"], .search.navbar-icon button, .search-toggle',
        title: 'Search',
        description: 'Looking for a project, a document, or a person? Find it instantly from anywhere on the platform.',
        side: 'bottom',
        align: 'start',
    },
    {
        element: '.avatar, .user-profile-toggle',
        title: 'Your Profile',
        description: 'Customise your profile, manage your portfolio, and control your visibility settings.',
        side: 'bottom',
        align: 'end',
    },
    {
        element: '.spaces-editor-form-container, .spaces-editor-post, #widget-widget-post-vue-widget-spaces-editor-widget-post',
        title: 'Add Post',
        description: 'Feel free to post something here: your work, a project update.<br><br><span class="kt-hint">💡 If you see <i>"Please send this message in another category"</i>, just select the appropriate category first before posting.</span>',
        side: 'top',
    },
    {
        element: '.ds-add-event-modal-trigger',
        title: 'Events',
        description: 'Add your exhibition, workshop, or presentation to the public calendar.',
        side: 'top',
    },
    {
        element: '[class*="featured"]',
        title: 'Featured & Tips',
        description: 'Pinned content, featured posts, and community tips surface here as you explore.',
        side: 'bottom',
        align: 'center',
    },
    {
        element: '#defaultspace-settings-menu, .defaultspace-settings-container',
        title: 'Extra Tools',
        description: 'Space settings, layout options, and power-user controls.',
        side: 'top',
    },
    {
        element: '#gt-nav-button',
        title: 'You know it all now',
        description: 'Tour complete. You can revisit either part anytime with this button.<br><br><button class="kt-explore-btn">Explore ✦</button><br><br><span class="kt-credits">by the Online Ag &nbsp;·&nbsp; <a href="mailto:tramatilea@gmail.com" class="kt-credits-link">Contact</a></span>',
        side: 'bottom',
        doneBtnText: 'Done',
    },
];

// ── Driver setup ──────────────────────────────────────────────────────────
let resolvedSteps = [];

const driverObj = window.driver.js.driver({
    showProgress: true,
    animate:      false,
    allowClose:   true,
    opacity:      0.75,
    nextBtnText:  'Next →',
    prevBtnText:  'Back',
    doneBtnText:  'Done',
    stagePadding: 14,
    stageRadius:  8,

    onHighlightStarted: async (_element, step) => {
        // Remove any CSS hover forced open by the previous step's prepare()
        document.querySelectorAll('._kt-hover-step').forEach(el => el.classList.remove('_kt-hover-step'));
        document.querySelectorAll('._kt-posts-container').forEach(el => el.classList.remove('_kt-posts-container'));
        document.querySelectorAll('._kt-course-sel').forEach(el => el.classList.remove('_kt-course-sel'));
        document.querySelectorAll('._kt-create-highlight').forEach(el => el.classList.remove('_kt-create-highlight'));
        if (openedFoundationSubmenu) {
            const { ul, anchor } = openedFoundationSubmenu;
            if (ul) { ul.classList.remove('js-dropdown-active'); ul.style.cssText = ''; }
            if (anchor) anchor.remove();
            openedFoundationSubmenu = null;
        }

        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 (raw.element && !$(raw.element)) {
            reportError('missing-element', { step: raw.title, selector: raw.element });
        }
    },

    // Wire up the "Go deeper" button once the popover is in the DOM.
    // Using onHighlighted (not an inline onclick) keeps this CSP-safe.
    onHighlighted: () => {
        const btn = document.querySelector('.driver-popover .kt-go-deeper-btn');
        if (btn && !btn._kisdWired) {
            btn._kisdWired = true;
            btn.addEventListener('click', goDeeper);
        }
        const endPopover = document.querySelector('.driver-popover.kt-essential-end');
        if (endPopover) {
            const doneBtn = endPopover.querySelector('.driver-popover-next-btn');
            if (doneBtn && !doneBtn._kisdWired) {
                doneBtn._kisdWired = true;
                doneBtn.addEventListener('click', () => driverObj.destroy(), { once: true });
            }
        }
        const exploreBtn = document.querySelector('.driver-popover .kt-explore-btn');
        if (exploreBtn && !exploreBtn._kisdWired) {
            exploreBtn._kisdWired = true;
            exploreBtn.addEventListener('click', () => driverObj.destroy());
        }
        const toggle = document.querySelector('.driver-popover .kt-lab-toggle');
        if (toggle && !toggle._kisdWired) {
            toggle._kisdWired = true;
            toggle.addEventListener('click', () => {
                const expand = document.querySelector('.driver-popover .kt-lab-expand');
                if (!expand) return;
                const open = expand.style.display === 'block';
                expand.style.display = open ? 'none' : 'block';
                toggle.textContent = open ? '▸ What is a lab?' : '▾ What is a lab?';
            });
        }
    },

    onDestroyStarted: () => {
        if (_tourDestroying) return;
        _tourDestroying = true;
        document.querySelectorAll('._kt-hover-step').forEach(el => el.classList.remove('_kt-hover-step'));
        document.querySelectorAll('._kt-posts-container').forEach(el => el.classList.remove('_kt-posts-container'));
        document.querySelectorAll('._kt-course-sel').forEach(el => el.classList.remove('_kt-course-sel'));
        document.querySelectorAll('._kt-create-highlight').forEach(el => el.classList.remove('_kt-create-highlight'));
        if (openedFoundationSubmenu) {
            const { ul, anchor } = openedFoundationSubmenu;
            if (ul) { ul.classList.remove('js-dropdown-active'); ul.style.cssText = ''; }
            if (anchor) anchor.remove();
            openedFoundationSubmenu = null;
        }
        if (openedMenuAnchorSelector) {
            const el = $(openedMenuAnchorSelector);
            if (el) el.click();
            openedMenuAnchorSelector = null;
        }
        window.removeEventListener('keydown', _onKeyNav,    { capture: true });
        window.removeEventListener('wheel',   _onWheelBlock, { capture: true });
        localStorage.setItem(STORAGE_KEY, 'true');
        driverObj.destroy();
    },
});

/**
 * Build driver.js steps from a raw array without running any prepare()s.
 * Steps whose target is absent AND have no prepare() are skipped up front.
 */
function buildSteps(rawSteps) {
    const steps = [];
    for (const raw of rawSteps) {
        const popover = {
            title:       raw.title,
            description: raw.description,
            side:        raw.side  || 'bottom',
            align:       raw.align || 'start',
            ...(raw.doneBtnText   && { doneBtnText:   raw.doneBtnText }),
            ...(raw.popoverClass  && { popoverClass:  raw.popoverClass }),
        };

        if (raw.center || !raw.element) {
            steps.push({ popover, _raw: raw });
            continue;
        }

        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(rawSteps) {
    _tourDestroying = false;
    window.addEventListener('keydown', _onKeyNav,    { capture: true });
    window.addEventListener('wheel',   _onWheelBlock, { passive: false, capture: true });
    try {
        resolvedSteps = buildSteps(rawSteps);
        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 });
    }
}

function startEssentialTour() { startTour(ESSENTIAL_STEPS); }
function startExtendedTour()  { startTour(EXTENDED_STEPS); }

function goDeeper() {
    // onDestroyStarted handles cleanup (listeners, localStorage, open menus)
    driverObj.destroy();
    setTimeout(startExtendedTour, 150);
}

// Also exposed for console testing: window.__kisdGoDeeper()
window.__kisdGoDeeper = goDeeper;

// ── 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.setAttribute('aria-label', 'Start guided tour of KISD Spaces');
    btn.innerHTML = '<span class="kt-spark" aria-hidden="true">✦</span><span>Tour</span>';
    btn.addEventListener('click', startEssentialTour);
    li.appendChild(btn);

    // Place next to the "feature" button if it exists in the nav
    const featureItem = navUl.querySelector('[class*="feature"], a[href*="feature"]');
    if (featureItem) {
        (featureItem.closest('li') || featureItem).after(li);
    } else {
        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(startEssentialTour, 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);
}

window.addEventListener('error', (e) => {
    if (!e || !e.message) return;
    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);

})();"

Best regards,

Léa Tramati.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment