From 50af132dccc82e58f6bc51641bf797969b9ad40d Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 14:22:42 +0200 Subject: [PATCH 1/3] Add interactive platform tour --- assets/controllers/mascot_controller.js | 268 +++++++++++++++++- assets/styles/app.css | 72 +++++ src/Controller/UserSettingsController.php | 21 ++ src/Entity/User.php | 16 ++ templates/base.html.twig | 25 +- templates/dashboard/index.html.twig | 2 + .../Controller/UserSettingsControllerTest.php | 29 ++ tests/Unit/Entity/UserTest.php | 12 + translations/messages.da.yaml | 14 + translations/messages.en.yaml | 14 + 10 files changed, 470 insertions(+), 3 deletions(-) diff --git a/assets/controllers/mascot_controller.js b/assets/controllers/mascot_controller.js index c5c288b..cd61cba 100644 --- a/assets/controllers/mascot_controller.js +++ b/assets/controllers/mascot_controller.js @@ -15,6 +15,8 @@ export default class extends Controller { "play", "finish", "finishContact", + "tourNext", + "tourSkip", ]; static values = { @@ -32,6 +34,15 @@ export default class extends Controller { farewell: String, welcome: String, intro: String, + tour: { type: Array, default: [] }, + tourPropose: String, + tourStart: String, + tourDecline: String, + tourNext: String, + tourSkip: String, + tourDone: String, + tourSeenUrl: String, + tourSeenToken: String, }; connect() { @@ -47,8 +58,16 @@ export default class extends Controller { // user turns it back on. Resume where the previous page left off so // navigating around doesn't pop a fresh message on every load. if (this.enabledValue) { - const sinceLast = Date.now() - this.lastShownAt; - this.scheduleNext(Math.max(1800, this.intervalValue - sinceLast)); + if (this.tourValue.length > 0) { + // A first-time visitor on the dashboard: offer Glimt's guided tour + // instead of the usual cheer cadence. + this.startTimer("cycle", () => this.proposeTour(), 1400); + } else { + const sinceLast = Date.now() - this.lastShownAt; + this.scheduleNext( + Math.max(1800, this.intervalValue - sinceLast), + ); + } } } @@ -418,6 +437,14 @@ export default class extends Controller { if (this.hasFinishContactTarget) { this.finishContactTarget.hidden = "finishContact" !== action; } + // A cheer message never carries the tour's own buttons; make sure none + // linger after the tour has ended. + if (this.hasTourNextTarget) { + this.tourNextTarget.hidden = true; + } + if (this.hasTourSkipTarget) { + this.tourSkipTarget.hidden = true; + } this.bubbleTarget.hidden = false; window.requestAnimationFrame(() => { this.bubbleTarget.classList.add("is-visible"); @@ -438,6 +465,11 @@ export default class extends Controller { // The × just closes the current bubble; the mascot stays and cheers again later. close() { + if ("tour" === this.mode) { + this.tourSkip(); + + return; + } if ("invited" === this.mode) { this.mode = "idle"; window.clearTimeout(this.inviteTimer); @@ -445,6 +477,238 @@ export default class extends Controller { this.hide(); } + // ---- Guided tour ------------------------------------------------------- + // Offered once to a first-time visitor on the dashboard. Glimt flies to each + // area, spotlights it and explains it; the user steps through with Next (or + // Skip). Either choice marks the tour seen so it is never proposed again. + proposeTour() { + this.mode = "tour"; + this.tourIndex = -1; + this.introduced = true; + this.tourSay(this.tourProposeValue, { + next: this.tourStartValue, + skip: this.tourDeclineValue, + }); + } + + tourNext() { + if ("tour" !== this.mode) { + return; + } + if (-1 === this.tourIndex) { + this.markTourSeen(); + } + this.tourIndex += 1; + if (this.tourIndex >= this.tourValue.length) { + this.endTour(); + + return; + } + this.showTourStep(); + } + + tourSkip() { + this.markTourSeen(); + this.endTour(); + } + + showTourStep() { + const step = this.tourValue[this.tourIndex]; + const last = this.tourIndex === this.tourValue.length - 1; + this.spotlight(step.targets); + if (!this.element.classList.contains("mascot--playing")) { + // Anchor at the avatar's current corner spot before switching to + // free positioning, so the first glide starts from where it sits. + const box = this.avatar.getBoundingClientRect(); + this.element.classList.add("mascot--playing"); + this.moveTo(box.left, box.top); + } + this.element.classList.add("mascot--tour"); + // The opening step has no target, so Glimt grows a little and speaks to + // the user directly. + this.element.classList.toggle("mascot--hero", 0 === this.tourIndex); + this.tourSay(step.text, { + next: last ? this.tourDoneValue : this.tourNextValue, + skip: last ? null : this.tourSkipValue, + }); + // Position after the bubble is laid out so it can be measured and kept + // clear of the spotlighted target. + window.requestAnimationFrame(() => this.positionNear(step)); + } + + endTour() { + this.spotlight(null); + if (this.spotlightEl) { + this.spotlightEl.remove(); + this.spotlightEl = null; + } + this.hide(); + this.element.classList.remove( + "mascot--playing", + "mascot--tour", + "mascot--hero", + ); + this.element.style.left = ""; + this.element.style.top = ""; + this.mode = "idle"; + this.scheduleNext(this.intervalValue); + } + + // The tour bubble carries its own Next/Skip buttons and never auto-hides — + // the user drives it. Hide the cheer-time actions while it is up. + tourSay(text, { next, skip } = {}) { + if (!this.hasTextTarget) { + return; + } + this.textTarget.textContent = text; + for (const target of [ + this.hasCtaTarget && this.ctaTarget, + this.hasPlayTarget && this.playTarget, + this.hasFinishTarget && this.finishTarget, + this.hasFinishContactTarget && this.finishContactTarget, + ]) { + if (target) { + target.hidden = true; + } + } + if (this.hasTourNextTarget) { + this.tourNextTarget.hidden = !next; + if (next) { + this.tourNextTarget.textContent = next; + } + } + if (this.hasTourSkipTarget) { + this.tourSkipTarget.hidden = !skip; + if (skip) { + this.tourSkipTarget.textContent = skip; + } + } + this.clearNamedTimer("show"); + this.bubbleTarget.hidden = false; + window.requestAnimationFrame(() => { + this.bubbleTarget.classList.add("is-visible"); + }); + } + + ensureSpotlight() { + if (!this.spotlightEl) { + this.spotlightEl = document.createElement("div"); + this.spotlightEl.className = "tour-spotlight"; + document.body.appendChild(this.spotlightEl); + } + + return this.spotlightEl; + } + + // One dimming overlay that punches a hole over each target via clip-path. + // Reusing a single element lets the holes glide (and grow/shrink) from section + // to section instead of the dim flashing off and back on. No targets = the + // whole screen dims. + spotlight(targets) { + const sp = this.ensureSpotlight(); + const first = !sp.classList.contains("is-visible"); + const rects = this.rectsFor(targets); + sp.style.clipPath = this.dimPath(rects); + if (first) { + window.requestAnimationFrame(() => sp.classList.add("is-visible")); + } + } + + rectsFor(targets) { + return (targets || []) + .map((selector) => document.querySelector(selector)) + .filter(Boolean) + .map((el) => el.getBoundingClientRect()); + } + + // A full-screen rectangle with up to two rectangular holes (even-odd fill). + // Always two holes — an unused one collapses to a point at the centre — so the + // path keeps a constant shape and animates between steps rather than jumping. + dimPath(rects) { + const w = window.innerWidth; + const h = window.innerHeight; + const pad = 6; + const cx = w / 2; + const cy = h / 2; + const hole = (rect) => { + if (!rect) { + return `M${cx} ${cy} L${cx} ${cy} L${cx} ${cy} L${cx} ${cy} Z`; + } + const x1 = rect.left - pad; + const y1 = rect.top - pad; + const x2 = rect.right + pad; + const y2 = rect.bottom + pad; + return `M${x1} ${y1} L${x2} ${y1} L${x2} ${y2} L${x1} ${y2} Z`; + }; + const outer = `M0 0 L${w} 0 L${w} ${h} L0 ${h} Z`; + + return `path(evenodd, "${outer} ${hole(rects[0])} ${hole(rects[1])}")`; + } + + unionRect(targets) { + const rects = this.rectsFor(targets); + if (0 === rects.length) { + return null; + } + const left = Math.min(...rects.map((r) => r.left)); + const top = Math.min(...rects.map((r) => r.top)); + const right = Math.max(...rects.map((r) => r.right)); + const bottom = Math.max(...rects.map((r) => r.bottom)); + + return { left, top, right, bottom, width: right - left }; + } + + // Place Glimt relative to the step's target area, per the step's placement. + // The bubble grows up-and-left from the avatar, so its measured size drives + // where the avatar lands and keeps the bubble on-screen. + positionNear(step) { + const avatar = 68; + const w = window.innerWidth; + const h = window.innerHeight; + const bubbleW = this.bubbleTarget.offsetWidth || 260; + const bubbleH = this.bubbleTarget.offsetHeight || 130; + const clampLeft = (x) => + Math.max(bubbleW - 56, Math.min(x, w - avatar - 12)); + const area = this.unionRect(step.targets); + const placement = step.placement || "below"; + + let left; + let top; + if ("corner" === placement) { + left = w - avatar - 24; + top = h - avatar - 24; + } else if (!area || "center" === placement) { + left = clampLeft(w / 2 - avatar + bubbleW / 2); + top = Math.max(bubbleH + 32, Math.round(h * 0.42)); + } else if ("right" === placement) { + left = w - avatar - 32; + top = Math.max(bubbleH + 24, Math.round(h / 2)); + } else if ("left" === placement) { + left = clampLeft(area.left - 20 - avatar); + top = Math.max(bubbleH + 16, area.bottom + 16); + } else { + const centre = area.left + area.width / 2; + left = clampLeft(centre - avatar + bubbleW / 2); + top = area.bottom + bubbleH + 22; + } + this.moveTo(left, top); + } + + markTourSeen() { + if (this.tourSeenSent || !this.tourSeenUrlValue) { + return; + } + this.tourSeenSent = true; + fetch(this.tourSeenUrlValue, { + method: "POST", + headers: { + "X-Requested-With": "fetch", + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ _token: this.tourSeenTokenValue }), + }).catch(() => {}); + } + // Pick a message different from the last one shown, so it never repeats twice. nextMessage() { const messages = this.messagesValue; diff --git a/assets/styles/app.css b/assets/styles/app.css index 2ade5d3..e3e1602 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -2060,6 +2060,68 @@ textarea { color: var(--itk-ink); } +.mascot__tour-actions { + display: flex; + align-items: center; + gap: var(--itk-space-2); +} + +.mascot__tour-actions:has(> :not([hidden])) { + margin-top: var(--itk-space-3); +} + +.mascot__tour-next { + padding: var(--itk-space-1) var(--itk-space-3); + border: none; + border-radius: var(--itk-radius-2); + background-color: var(--itk-primary); + color: #fff; + font: inherit; + font-size: var(--itk-text-xs); + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s; +} + +.mascot__tour-next:hover { + background-color: var(--itk-primary-hover); +} + +.mascot__tour-skip { + padding: var(--itk-space-1) var(--itk-space-2); + border: none; + background: none; + color: var(--itk-slate-500); + font: inherit; + font-size: var(--itk-text-xs); + cursor: pointer; +} + +.mascot__tour-skip:hover { + color: var(--itk-ink); +} + +.mascot__tour-next[hidden], +.mascot__tour-skip[hidden] { + display: none; +} + +.tour-spotlight { + position: fixed; + inset: 0; + z-index: 150; + background-color: rgba(17, 19, 24, 0.55); + opacity: 0; + pointer-events: none; +} + +.tour-spotlight.is-visible { + opacity: 1; + transition: + clip-path 0.5s ease, + opacity 0.3s ease; +} + .mascot--playing { right: auto; bottom: auto; @@ -2077,6 +2139,16 @@ textarea { max-width: 280px; } +.mascot--tour { + transition: + left 0.6s ease, + top 0.6s ease; +} + +.mascot--hero .mascot__avatar { + transform: scale(1.35); +} + .mascot--chasing { animation: none; transition: none; diff --git a/src/Controller/UserSettingsController.php b/src/Controller/UserSettingsController.php index c203f24..3b7582c 100644 --- a/src/Controller/UserSettingsController.php +++ b/src/Controller/UserSettingsController.php @@ -52,6 +52,27 @@ public function toggleStars(Request $request, EntityManagerInterface $entityMana return $this->redirect($this->safeReturn($request)); } + /** + * Mark Glimt's guided tour as seen, so it is proposed only once. Answers 204 + * to the mascot's fetch and otherwise redirects back for the no-JS path. + */ + #[Route('/settings/tour/seen', name: 'app_settings_tour_seen', methods: ['POST'])] + public function markTourSeen(Request $request, EntityManagerInterface $entityManager): Response + { + $user = $this->getUser(); + if ($user instanceof User + && $this->isCsrfTokenValid('tour-seen', (string) $request->request->get('_token'))) { + $user->setTourSeen(true); + $entityManager->flush(); + } + + if ('fetch' === $request->headers->get('X-Requested-With')) { + return new Response(null, Response::HTTP_NO_CONTENT); + } + + return $this->redirect($this->safeReturn($request)); + } + /** * Only follow local, relative return paths to avoid open redirects (same * guard as the locale switcher). diff --git a/src/Entity/User.php b/src/Entity/User.php index 400fb9d..afc4327 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -160,6 +160,22 @@ public function setStarsEnabled(bool $enabled): static return $this; } + /** + * Whether the user has been offered Glimt's guided tour of the platform. It + * is proposed once, on the first dashboard visit, then never again. + */ + public function isTourSeen(): bool + { + return (bool) ($this->userSettings['tourSeen'] ?? false); + } + + public function setTourSeen(bool $seen): static + { + $this->userSettings['tourSeen'] = $seen; + + return $this; + } + public function eraseCredentials(): void { } diff --git a/templates/base.html.twig b/templates/base.html.twig index e4181db..f7e49a5 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -24,7 +24,7 @@
{{ 'nav.dashboard'|trans }} - {{ 'nav.initiatives'|trans }} + {{ 'nav.initiatives'|trans }}
@@ -132,6 +132,14 @@ 'mascot.contact.name_only'|trans(contactParams), ] %} {% endif %} + {% set showTour = app.request.attributes.get('_route') == 'app_dashboard' and not app.user.tourSeen %} + {% set tourSteps = [ + {text: 'tour.steps.intro'|trans, targets: [], placement: 'center'}, + {text: 'tour.steps.dashboard'|trans, targets: ['#dashboard-content'], placement: 'right'}, + {text: 'tour.steps.create'|trans, targets: ['#navInitiatives', '.page__actions .btn--primary'], placement: 'below'}, + {text: 'tour.steps.admin'|trans, targets: ['#userMenuToggle'], placement: 'left'}, + {text: 'tour.steps.outro'|trans, targets: [], placement: 'corner'}, + ] %}
+
{% endblock %} diff --git a/tests/Controller/UserSettingsControllerTest.php b/tests/Controller/UserSettingsControllerTest.php index ca31211..3a3119b 100644 --- a/tests/Controller/UserSettingsControllerTest.php +++ b/tests/Controller/UserSettingsControllerTest.php @@ -73,6 +73,27 @@ public function testStarsToggleRedirectsAndFlipsThePreference(): void self::assertFalse($this->reloadEditor()->isStarsEnabled()); } + public function testTourSeenAjaxReturnsNoContentAndPersists(): void + { + $this->loginAsEditor(); + $token = $this->tourSeenToken(); + + $this->client->request('POST', '/settings/tour/seen', ['_token' => $token], [], ['HTTP_X-Requested-With' => 'fetch']); + $this->assertResponseStatusCodeSame(204); + self::assertTrue($this->reloadEditor()->isTourSeen()); + } + + public function testTourSeenPlainFormRedirectsToReturn(): void + { + $this->loginAsEditor(); + $token = $this->tourSeenToken(); + + $this->client->request('POST', '/settings/tour/seen', ['_token' => $token, 'return' => '/initiatives']); + + $this->assertResponseRedirects('/initiatives'); + self::assertTrue($this->reloadEditor()->isTourSeen()); + } + private function mascotToggleToken(): string { $crawler = $this->client->request('GET', '/'); @@ -81,6 +102,14 @@ private function mascotToggleToken(): string return (string) $crawler->filter('#mascotToggleForm input[name="_token"]')->attr('value'); } + private function tourSeenToken(): string + { + $crawler = $this->client->request('GET', '/'); + $this->assertResponseIsSuccessful(); + + return (string) $crawler->filter('.mascot')->attr('data-mascot-tour-seen-token-value'); + } + private function reloadEditor(): User { $this->entityManager()->clear(); diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php index 5e86360..30223ef 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -92,6 +92,18 @@ public function testStarsPreferenceDefaultsEnabledAndToggles(): void self::assertTrue($user->isStarsEnabled()); } + public function testTourSeenDefaultsFalseAndPersists(): void + { + $user = new User(); + + // The guided tour has not been offered to a fresh user yet. + self::assertFalse($user->isTourSeen()); + + $user->setTourSeen(true); + self::assertTrue($user->isTourSeen()); + self::assertSame(['tourSeen' => true], $user->getUserSettings()); + } + public function testEraseCredentialsDoesNothing(): void { $user = new User(); diff --git a/translations/messages.da.yaml b/translations/messages.da.yaml index f1b03f5..565d693 100644 --- a/translations/messages.da.yaml +++ b/translations/messages.da.yaml @@ -47,6 +47,20 @@ mascot: giveup: "Pyha… lad os kalde det uafgjort. 😅" escaped: "Argh, du slap væk! Du er alt for hurtig. 🏳️" +tour: + propose: "Velkommen! Skal jeg vise dig kort rundt på platformen?" + start: Ja tak, vis rundt + decline: Nej tak + next: Næste + skip: Spring over + done: Så er jeg klar + steps: + intro: "Det her er Projektdatabasen — her samler vi afdelingernes initiativer og projekter ét sted, så vi kan se, hvad der sker på tværs." + dashboard: "På overblikket ser du nøgletal, status og eventuelle muligheder for samarbejds på tværs af afdelinger." + create: "Se alle initiativer via menupunktet Initiativer — eller opret et nyt med det samme via Opret-knappen." + admin: "Inde i menuen her finder du menupunktet Administration, hvor du kan oprette, se og redigere afdelinger, områder og kontaktpersoner." + outro: "Så er du klar! Jeg er altid lige her i hjørnet, hvis du får brug for et tip. 🌟" + autosave: saving: Gemmer… saved: Gemt diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index e0ac7b9..025ea46 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -47,6 +47,20 @@ mascot: giveup: "Phew… let's call it a draw. 😅" escaped: "Argh, you got away! Too quick for me. 🏳️" +tour: + propose: "Welcome! Shall I give you a quick tour of the platform?" + start: "Yes, show me around" + decline: No thanks + next: Next + skip: Skip + done: I'm ready + steps: + intro: "This is the Project database — where we gather the departments' initiatives and projects in one place, so we can see what's happening across them." + dashboard: "The overview shows key figures, status and any opportunities for collaboration across departments." + create: "Browse every initiative from the Initiativer menu — or create a new one right away with the Opret button." + admin: "Inside this menu you'll find Administration, where you can create, view and edit departments, areas and contacts." + outro: "You're all set! I'm always right here in the corner if you need a tip. 🌟" + autosave: saving: Saving… saved: Saved From dba97eb1a5f1c53fda214716d90f2ff789368a4b Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 14:28:04 +0200 Subject: [PATCH 2/3] Fixed spacing between Glimt and speech bubble upon scaling --- assets/styles/app.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/assets/styles/app.css b/assets/styles/app.css index e3e1602..ed6fd4c 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -2146,7 +2146,11 @@ textarea { } .mascot--hero .mascot__avatar { - transform: scale(1.35); + transform: scale(1.7); +} + +.mascot--hero .mascot__bubble { + bottom: calc(100% + var(--itk-space-6)); } .mascot--chasing { From 39bb09aa1e05237b564031e23679117fc249ea86 Mon Sep 17 00:00:00 2001 From: Jeppe Krogh Date: Mon, 29 Jun 2026 14:28:13 +0200 Subject: [PATCH 3/3] Updated changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d16fbb3..34c2809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +* [PR-21](https://github.com/itk-dev/itk-project-database/pull/21) + Add a first-login guided tour where Glimt walks new users through the platform, + dashboard, creating initiatives and the admin panel. * [PR-18](https://github.com/itk-dev/itk-project-database/pull/18) Rework the dashboard with an outstanding-work panel and a redesigned activity feed, add help text to every graph, and fix the mascot's finish nudges.