From d2d58b7313298863687db966ddbba5293db77e81 Mon Sep 17 00:00:00 2001 From: Guilhem Lemouel Date: Tue, 8 Sep 2026 11:33:54 +0200 Subject: [PATCH] feat(frontend): keep the operator onboarding tour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators cannot create anything, so the home page is the whole product to them and its three tabs are worth naming. The tour that did that is the one piece of the removed tutorial system that still has an audience. Restored trimmed: driver.js, the driver wrapper and its controls, the `.driver-popover` styling, and a module for the progress bit. The catalogue machinery it used to sit in — the config, the role gating, the router, the banner and the tutorials page — stays deleted, so the five steps are reached directly instead of through a registry of one. It runs on an operator's first home page visit and is recorded as seen however it ends, including navigating away; afterwards it is in the sidebar menu under Take the tour, which is where the last step points. Progress uses the surviving `tutorial_progress` route, slot 6, read-modify-written so the slots of the removed tutorials keep their state. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JirHCYVR6qg7Xqe4PcZ1KG --- frontend/package-lock.json | 7 ++ frontend/package.json | 1 + frontend/src/lib/assets/app.css | 12 ++ .../components/sidebar/OperatorMenu.svelte | 19 +++- .../components/tutorials/OperatorTour.svelte | 81 ++++++++++++++ .../lib/components/tutorials/Tutorial.svelte | 103 ++++++++++++++++++ .../tutorials/TutorialControls.svelte | 41 +++++++ .../components/tutorials/TutorialInner.svelte | 3 + .../lib/components/tutorials/operatorTour.ts | 47 ++++++++ .../src/routes/(root)/(logged)/+page.svelte | 47 ++++++++ 10 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/components/tutorials/OperatorTour.svelte create mode 100644 frontend/src/lib/components/tutorials/Tutorial.svelte create mode 100644 frontend/src/lib/components/tutorials/TutorialControls.svelte create mode 100644 frontend/src/lib/components/tutorials/TutorialInner.svelte create mode 100644 frontend/src/lib/components/tutorials/operatorTour.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4761b01b04..1febb20b83 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -43,6 +43,7 @@ "date-fns": "^2.30.0", "diff": "^7.0.0", "dompurify": "^3.3.1", + "driver.js": "^1.3.0", "esm-env": "^1.0.0", "fast-equals": "^5.0.1", "graphql": "^16.7.1", @@ -5563,6 +5564,12 @@ "url": "https://dotenvx.com" } }, + "node_modules/driver.js": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/driver.js/-/driver.js-1.8.0.tgz", + "integrity": "sha512-+8/IO7h1v14IzWh2GP60N7T3PFZweXwdn5e5POuxRSBoCYUojsBxzqawPeXh3YZIibRy7EehYNEyxe7slwwtdg==", + "license": "MIT" + }, "node_modules/dts-bundle-generator": { "version": "9.5.1", "resolved": "https://registry.npmjs.org/dts-bundle-generator/-/dts-bundle-generator-9.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index f8d4261f93..d96e512c00 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -119,6 +119,7 @@ "date-fns": "^2.30.0", "diff": "^7.0.0", "dompurify": "^3.3.1", + "driver.js": "^1.3.0", "esm-env": "^1.0.0", "fast-equals": "^5.0.1", "graphql": "^16.7.1", diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index 3e9aa42728..f6dc648234 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -302,6 +302,18 @@ } } +.driver-popover-title { + @apply leading-6 !text-primary !text-base; +} + +.driver-popover-description { + @apply !text-secondary !text-sm; +} + +.driver-popover { + @apply p-6 !bg-surface !max-w-2xl; +} + .panel-item { @apply border dark:border-gray-600 border-gray-200 flex gap-1 truncate font-normal justify-between w-full items-center py-1 px-2 rounded-sm duration-200; } diff --git a/frontend/src/lib/components/sidebar/OperatorMenu.svelte b/frontend/src/lib/components/sidebar/OperatorMenu.svelte index 8a4ee10dfe..fa59d730df 100644 --- a/frontend/src/lib/components/sidebar/OperatorMenu.svelte +++ b/frontend/src/lib/components/sidebar/OperatorMenu.svelte @@ -12,9 +12,11 @@ Building, Calendar, ServerCog, - Table2 + Table2, + GraduationCap } from 'lucide-svelte' import { base } from '$lib/base' + import { TOUR_PARAM, TOUR_PARAM_VALUE } from '$lib/components/tutorials/operatorTour' import MultiplayerMenu from './MultiplayerMenu.svelte' import { Plus } from 'lucide-svelte' @@ -228,6 +230,21 @@ Account settings + + + + Take the tour +
diff --git a/frontend/src/lib/components/tutorials/OperatorTour.svelte b/frontend/src/lib/components/tutorials/OperatorTour.svelte new file mode 100644 index 0000000000..cc7f4ab4a5 --- /dev/null +++ b/frontend/src/lib/components/tutorials/OperatorTour.svelte @@ -0,0 +1,81 @@ + + + { + const steps: DriveStep[] = [ + { + popover: { + title: 'Welcome to Windmill! 🎉', + description: + "Let's take a quick tour! We'll show you the three main tools you can use: Scripts, Flows, and Apps." + } + }, + { + popover: { + title: 'Scripts - Run automated tasks', + description: + 'Script Example

Scripts are ready-to-use tasks that do things automatically for you.

You can run scripts whenever you need them - like generating a report, sending notifications, or processing data.

' + }, + element: '[data-value="script"]' + }, + { + popover: { + title: 'Flows - Run step-by-step processes', + description: + 'Flow

Flows are processes that run multiple tasks in order, one after another.

You can start a flow and watch it complete each step automatically - perfect for tasks that have multiple stages.

' + }, + element: '[data-value="flow"]' + }, + { + popover: { + title: 'Apps - Use custom tools', + description: + 'App

Apps are easy-to-use tools with buttons, forms, and displays built just for your team.

You can open an app to work with your data, fill out forms, or trigger tasks - no technical knowledge needed!

' + }, + element: '[data-value="app"]' + }, + { + popover: { + title: 'Finally, the Menu section', + description: + 'Explore available tabs where you can access your history of runs, your scheduled scripts, and your workspaces.

💡 Want to see this again? Pick Take the tour from that same menu.

', + onNextClick: async () => { + // The step points into the menu, so it has to be open before the popover + // lands on it — and open is also where the entry to re-run the tour is. + const menuButton = document.querySelector('[role="menuitem"]') as HTMLElement | null + menuButton?.click() + await wait(MENU_OPEN_DELAY_MS) + driver.destroy() + } + }, + element: '[role="menuitem"]' + } + ] + + return steps + }} +/> diff --git a/frontend/src/lib/components/tutorials/Tutorial.svelte b/frontend/src/lib/components/tutorials/Tutorial.svelte new file mode 100644 index 0000000000..d4c99482a2 --- /dev/null +++ b/frontend/src/lib/components/tutorials/Tutorial.svelte @@ -0,0 +1,103 @@ + + +{#if tutorial} + +{/if} diff --git a/frontend/src/lib/components/tutorials/TutorialControls.svelte b/frontend/src/lib/components/tutorials/TutorialControls.svelte new file mode 100644 index 0000000000..dd15cf4151 --- /dev/null +++ b/frontend/src/lib/components/tutorials/TutorialControls.svelte @@ -0,0 +1,41 @@ + + +
+ {#if activeIndex === 0} + +
  • UI is not interactive during the tour, press next at every step
  • +
  • You can use the arrow keys to navigate
  • +
    + {/if} +
    +
    + Step {activeIndex + 1} of {totalSteps} +
    +
    + + +
    +
    +
    diff --git a/frontend/src/lib/components/tutorials/TutorialInner.svelte b/frontend/src/lib/components/tutorials/TutorialInner.svelte new file mode 100644 index 0000000000..ce0784ba8e --- /dev/null +++ b/frontend/src/lib/components/tutorials/TutorialInner.svelte @@ -0,0 +1,3 @@ + diff --git a/frontend/src/lib/components/tutorials/operatorTour.ts b/frontend/src/lib/components/tutorials/operatorTour.ts new file mode 100644 index 0000000000..5c4269e84d --- /dev/null +++ b/frontend/src/lib/components/tutorials/operatorTour.ts @@ -0,0 +1,47 @@ +import { UserService } from '$lib/gen' + +/** + * The tour's slot in the `tutorial_progress` bitmask. Slot 6 is reserved for it across + * versions: an operator who has already been through the tour must not meet it again, and + * a slot that another tutorial writes would read as finished on day one. + */ +const OPERATOR_TOUR_BIT = 6 + +/** URL parameter the sidebar entry uses to ask the home page for a run. */ +export const TOUR_PARAM = 'tour' +export const TOUR_PARAM_VALUE = 'operator' + +/** Long enough for the home page's tabs to exist before the first step points at one. */ +export const TOUR_START_DELAY_MS = 500 +/** Time for the sidebar to open before the last step points into it. */ +export const MENU_OPEN_DELAY_MS = 300 + +export async function hasSeenOperatorTour(): Promise { + // A failure answers "seen": the tour interrupts the page, and interrupting someone who + // has already been through it is worse than never offering it, which the sidebar entry + // covers anyway. + try { + const progress = (await UserService.getTutorialProgress()).progress ?? 0 + return (progress & (1 << OPERATOR_TOUR_BIT)) !== 0 + } catch (error) { + console.error('Could not read tutorial progress:', error) + return true + } +} + +export async function markOperatorTourSeen(): Promise { + try { + // Read-modify-write, because the row is shared: it carries every slot's state, and a + // write of this bit alone would clear the rest. `skipped_all` rides along for the same + // reason — and the handler rejects a body without it, whatever the generated type says. + const current = await UserService.getTutorialProgress() + await UserService.updateTutorialProgress({ + requestBody: { + progress: (current.progress ?? 0) | (1 << OPERATOR_TOUR_BIT), + skipped_all: current.skipped_all ?? false + } + }) + } catch (error) { + console.error('Could not record tutorial progress:', error) + } +} diff --git a/frontend/src/routes/(root)/(logged)/+page.svelte b/frontend/src/routes/(root)/(logged)/+page.svelte index b2035e2ed4..c1f4f4818d 100644 --- a/frontend/src/routes/(root)/(logged)/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/+page.svelte @@ -28,6 +28,14 @@ import { z } from 'zod' import HomeAIChat from '$lib/components/home/HomeAIChat.svelte' import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' + import { onMount, untrack } from 'svelte' + import OperatorTour from '$lib/components/tutorials/OperatorTour.svelte' + import { + hasSeenOperatorTour, + TOUR_PARAM, + TOUR_PARAM_VALUE, + TOUR_START_DELAY_MS + } from '$lib/components/tutorials/operatorTour' type Tab = 'hub' | 'workspace' @@ -83,6 +91,41 @@ } let showCreateButtons = $state(false) + + let operatorTour: OperatorTour | undefined = $state(undefined) + + // Delayed so the tabs the first steps point at exist. `runTutorial` refuses while a tour is + // already running, which is the guard that matters — the tour ends by telling the operator + // to start it again from the menu, so a start has to be possible for the life of the page. + function startTour() { + setTimeout(() => operatorTour?.runTutorial(), TOUR_START_DELAY_MS) + } + + // The sidebar entry asks by URL parameter so it works from any page an operator can be on. + // Read reactively rather than on mount: arriving from the menu while already on the home + // page is a parameter change, not a new page. + $effect(() => { + if (page.url.searchParams.get(TOUR_PARAM) !== TOUR_PARAM_VALUE) return + const user = $userStore + if (!user) return + untrack(() => { + const url = new URL(page.url) + url.searchParams.delete(TOUR_PARAM) + replaceState(url, page.state) + // Gated here too: the parameter is part of a URL anyone can type, and the tour + // describes a home page that only operators see. + if (user.operator) startTour() + }) + }) + + onMount(async () => { + // Operators get the tour once, and only when they have not been through it: they cannot + // create anything, so the home page is the whole product to them and it is worth naming + // its three tabs. Anyone who can build gets nothing — they have the create button. + if (!$userStore?.operator || page.url.searchParams.has(TOUR_PARAM)) return + if (await hasSeenOperatorTour()) return + startTour() + }) @@ -324,6 +367,10 @@ {/if}
    +{#if $userStore?.operator} + +{/if} +