From e8fd38e98a9304cb6001ccccff538d0e50b24262 Mon Sep 17 00:00:00 2001 From: Sebastian Frank Date: Thu, 26 Feb 2026 02:42:16 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20video=20tour=20functi?= =?UTF-8?q?onality=20with=20helpers,=20configuration,=20and=20homepage=20w?= =?UTF-8?q?alkthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + AGENTS.md | 10 ++ package.json | 4 +- video-tours/helpers.ts | 141 +++++++++++++++++++++++++++++ video-tours/playwright.config.ts | 46 ++++++++++ video-tours/tour-constants.ts | 16 ++++ video-tours/tours/fixtures.ts | 29 ++++++ video-tours/tours/homepage.tour.ts | 49 ++++++++++ 8 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 video-tours/helpers.ts create mode 100644 video-tours/playwright.config.ts create mode 100644 video-tours/tour-constants.ts create mode 100644 video-tours/tours/fixtures.ts create mode 100644 video-tours/tours/homepage.tour.ts diff --git a/.gitignore b/.gitignore index 925372f..0981d04 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ test-results/ playwright-report/ playwright/.cache/ visual-review/ +video-tours/output/ .yarn/* !.yarn/cache !.yarn/patches diff --git a/AGENTS.md b/AGENTS.md index cd9129f..0737045 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,16 @@ Tibi CMS starter template — Svelte 5 SPA with esbuild, SSR via goja, and Playw - After code changes, run only affected spec files: `npx playwright test tests/e2e/filename.spec.ts`. - Write unit tests for new functionality and ensure existing tests pass. +## Video tours + +- Video tours are Playwright-based screen recordings (not tests) in `video-tours/`. +- Run all tours (desktop): `yarn tour` +- Run all tours (mobile): `yarn tour:mobile` +- Videos are saved to `video-tours/output/` (git-ignored). +- Tour files use `.tour.ts` suffix in `video-tours/tours/`. +- Helpers: `injectVisibleCursor()`, `moveThenClick()`, `moveThenType()`, `smoothScroll()` in `video-tours/helpers.ts`. +- Fixtures provide a `tourPage` with visible cursor overlay via `video-tours/tours/fixtures.ts`. + ## API access - API access to collections uses the reverse proxy: `CODING_URL/api/` (e.g. `CODING_URL/api/content`). diff --git a/package.json b/package.json index cb9ed8f..ee682f0 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "test:api": "playwright test tests/api", "test:visual": "playwright test --project=visual-desktop --project=visual-iphonese --project=visual-ipad", "test:visual:update": "playwright test --project=visual-desktop --project=visual-iphonese --project=visual-ipad --update-snapshots", - "test:report": "playwright show-report" + "test:report": "playwright show-report", + "tour": "playwright test --config=video-tours/playwright.config.ts --project=tour-desktop", + "tour:mobile": "playwright test --config=video-tours/playwright.config.ts --project=tour-mobile" }, "devDependencies": { "@babel/cli": "^7.28.6", diff --git a/video-tours/helpers.ts b/video-tours/helpers.ts new file mode 100644 index 0000000..269b47b --- /dev/null +++ b/video-tours/helpers.ts @@ -0,0 +1,141 @@ +import type { Page } from "@playwright/test" + +/** + * Injects a visible CSS cursor overlay that follows mouse movements. + * Features a glowing blue dot with an expanding ripple ring on click. + * + * Must be called BEFORE the first page.goto() (uses addInitScript). + */ +export async function injectVisibleCursor(page: Page, options?: { color?: string; size?: number }): Promise { + const color = options?.color ?? "59, 130, 246" // blue-500 + const size = options?.size ?? 24 + + await page.addInitScript( + ({ color, size }) => { + document.addEventListener("DOMContentLoaded", () => { + // Avoid duplicates on soft-navigations + if (document.getElementById("pw-cursor")) return + + // ── Cursor dot ── + const cursor = document.createElement("div") + cursor.id = "pw-cursor" + Object.assign(cursor.style, { + width: `${size}px`, + height: `${size}px`, + borderRadius: "50%", + background: `radial-gradient(circle, rgba(${color}, 0.9) 0%, rgba(${color}, 0.4) 60%, transparent 100%)`, + boxShadow: `0 0 ${size / 2}px rgba(${color}, 0.5), 0 0 ${size}px rgba(${color}, 0.2)`, + position: "fixed", + zIndex: "999999", + pointerEvents: "none", + transition: "transform 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.12s ease", + transform: "translate(-50%, -50%)", + top: "-100px", + left: "-100px", + }) + document.body.appendChild(cursor) + + // ── Click ripple animation ── + const style = document.createElement("style") + style.textContent = ` + @keyframes pw-ripple { + 0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0.8; } + 100% { transform: translate(-50%, -50%) scale(2.5); opacity: 0; } + } + .pw-ripple { + position: fixed; + width: ${size * 2.5}px; + height: ${size * 2.5}px; + border-radius: 50%; + border: 2.5px solid rgba(${color}, 0.7); + pointer-events: none; + z-index: 999998; + animation: pw-ripple 0.5s cubic-bezier(0, 0, 0.2, 1) forwards; + } + ` + document.head.appendChild(style) + + let cx = -100, + cy = -100 + + document.addEventListener("mousemove", (e) => { + cx = e.clientX + cy = e.clientY + cursor.style.left = cx + "px" + cursor.style.top = cy + "px" + }) + + document.addEventListener("mousedown", () => { + // Shrink cursor + intensify glow + cursor.style.transform = "translate(-50%, -50%) scale(0.5)" + cursor.style.boxShadow = `0 0 ${size}px rgba(${color}, 0.8), 0 0 ${size * 1.5}px rgba(${color}, 0.4)` + + // Spawn expanding ripple ring + const ripple = document.createElement("div") + ripple.className = "pw-ripple" + ripple.style.left = cx + "px" + ripple.style.top = cy + "px" + document.body.appendChild(ripple) + ripple.addEventListener("animationend", () => ripple.remove()) + }) + + document.addEventListener("mouseup", () => { + cursor.style.transform = "translate(-50%, -50%) scale(1)" + cursor.style.boxShadow = `0 0 ${size / 2}px rgba(${color}, 0.5), 0 0 ${size}px rgba(${color}, 0.2)` + }) + }) + }, + { color, size } + ) +} + +/** + * Moves the visible cursor smoothly to a target element before clicking. + * This ensures mousemove events fire so the injected cursor follows along. + */ +export async function moveThenClick(page: Page, locator: ReturnType): Promise { + const box = await locator.boundingBox() + if (box) { + const x = box.x + box.width / 2 + const y = box.y + box.height / 2 + // Smooth move in steps + await page.mouse.move(x, y, { steps: 15 }) + await page.waitForTimeout(150) + } + await locator.click() +} + +/** + * Moves the visible cursor to a target element and types text character by character. + * Simulates realistic typing for demo purposes. + */ +export async function moveThenType( + page: Page, + locator: ReturnType, + text: string, + options?: { delay?: number } +): Promise { + const box = await locator.boundingBox() + if (box) { + const x = box.x + box.width / 2 + const y = box.y + box.height / 2 + await page.mouse.move(x, y, { steps: 15 }) + await page.waitForTimeout(150) + } + await locator.click() + await page.waitForTimeout(200) + await page.keyboard.type(text, { delay: options?.delay ?? 80 }) +} + +/** + * Scrolls the page smoothly to a given position. + */ +export async function smoothScroll(page: Page, y: number, duration = 600): Promise { + await page.evaluate( + ({ y, duration }) => { + window.scrollTo({ top: y, behavior: "smooth" }) + return new Promise((r) => setTimeout(r, duration)) + }, + { y, duration } + ) +} diff --git a/video-tours/playwright.config.ts b/video-tours/playwright.config.ts new file mode 100644 index 0000000..0f555ac --- /dev/null +++ b/video-tours/playwright.config.ts @@ -0,0 +1,46 @@ +import { defineConfig, devices } from "@playwright/test" + +/** + * Playwright config for video tours (demos, not tests). + * + * Usage: + * yarn tour # run all tours (desktop) + * yarn tour:mobile # run all tours (mobile) + * + * Videos are saved to video-tours/output/ + */ +export default defineConfig({ + testDir: "./tours", + testMatch: "**/*.tour.ts", + outputDir: "./output", + fullyParallel: false, + retries: 0, + workers: 1, + reporter: "list", + /* No global setup/teardown – tours are self-contained */ + + use: { + baseURL: process.env.CODING_URL || "https://localhost:3000", + headless: true, + ignoreHTTPSErrors: true, + video: { mode: "on", size: { width: 1280, height: 720 } }, + navigationTimeout: 30000, + launchOptions: { + args: ["--no-sandbox"], + }, + }, + + projects: [ + { + name: "tour-desktop", + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "tour-mobile", + use: { + ...devices["iPhone 12"], + defaultBrowserType: "chromium", + }, + }, + ], +}) diff --git a/video-tours/tour-constants.ts b/video-tours/tour-constants.ts new file mode 100644 index 0000000..e067c34 --- /dev/null +++ b/video-tours/tour-constants.ts @@ -0,0 +1,16 @@ +/** + * Constants for video tour configuration. + * + * Adapt these values to your project. The tour user is designed + * to persist across runs so that on repeat runs the tour simply + * logs in instead of re-registering. + */ + +// ── Tour User (adjust for your project) ────────────────────────────── +export const TOUR_USER = { + email: "tour-demo@test.example.com", + password: "TourDemo2025!", + firstName: "Max", + lastName: "Mustermann", + organization: "Mustermann GmbH", +} as const diff --git a/video-tours/tours/fixtures.ts b/video-tours/tours/fixtures.ts new file mode 100644 index 0000000..4e6c91e --- /dev/null +++ b/video-tours/tours/fixtures.ts @@ -0,0 +1,29 @@ +import { test as base, type Page } from "@playwright/test" +import { injectVisibleCursor } from "../helpers" + +/** + * Extended test fixtures for video tours. + * + * - `tourPage`: A page with visible cursor overlay, ready for recording. + * + * Add project-specific setup (cookie consent, modals, etc.) here. + */ +type TourFixtures = { + tourPage: Page +} + +export const tour = base.extend({ + tourPage: async ({ page }, use) => { + // Inject visible cursor (must be before first goto) + await injectVisibleCursor(page) + + // Override navigation to always use domcontentloaded (BrowserSync keeps WS open) + const origGoto = page.goto.bind(page) + page.goto = ((url: string, opts?: any) => + origGoto(url, { waitUntil: "domcontentloaded", ...opts })) as typeof page.goto + + await use(page) + }, +}) + +export { expect } from "@playwright/test" diff --git a/video-tours/tours/homepage.tour.ts b/video-tours/tours/homepage.tour.ts new file mode 100644 index 0000000..5ebcea9 --- /dev/null +++ b/video-tours/tours/homepage.tour.ts @@ -0,0 +1,49 @@ +import { tour } from "./fixtures" +import { moveThenClick, smoothScroll } from "../helpers" + +/** + * Video tour: Homepage walkthrough + * + * Demonstrates the main page navigation: + * 1. Homepage overview with scroll + * 2. Navigation links + * + * Adapt the steps below when the UI changes. + * Run: yarn tour + */ +tour("Homepage Walkthrough", async ({ tourPage: page }) => { + tour.setTimeout(60000) + + // ── 1. Homepage ────────────────────────────────────────────────── + await page.goto("/") + await page.waitForTimeout(2500) + + // Scroll down to see page content + await smoothScroll(page, 400) + await page.waitForTimeout(2000) + + // Scroll back up + await smoothScroll(page, 0) + await page.waitForTimeout(1000) + + // ── 2. Navigate via links ──────────────────────────────────────── + const navLinks = page.locator("nav a") + const count = await navLinks.count() + + for (let i = 0; i < Math.min(count, 3); i++) { + const link = navLinks.nth(i) + if (await link.isVisible({ timeout: 2000 }).catch(() => false)) { + await moveThenClick(page, link) + await page.waitForTimeout(2000) + + // Scroll a bit on each page + await smoothScroll(page, 300) + await page.waitForTimeout(1500) + await smoothScroll(page, 0) + await page.waitForTimeout(500) + } + } + + // Final pause + await page.waitForTimeout(1500) +})