From a58dc2bd9cda03274a2e53a6eba6f8453c607844 Mon Sep 17 00:00:00 2001 From: Shad Date: Mon, 15 Jun 2026 00:23:37 +0000 Subject: [PATCH] feat(onboarding): first-visit 5-step highlight tour (#11) --- src/App.tsx | 2 + src/components/OnboardingTour.tsx | 141 ++++++++++++++++++++++++++++++ src/index.css | 55 ++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 src/components/OnboardingTour.tsx diff --git a/src/App.tsx b/src/App.tsx index 1674ce6..5c69bd2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import Documents from "./scenes/Documents"; import CommandBar from "./components/CommandBar"; import Toaster from "./components/Toaster"; import Console from "./components/Console"; +import OnboardingTour from "./components/OnboardingTour"; import { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon, Bot, Bell } from "./components/icons"; import { useBackendHealth } from "./lib/useBackendHealth"; @@ -272,6 +273,7 @@ export default function App() { + {isAuthed && scene !== "login" && scene !== "sso-callback" && } ); } diff --git a/src/components/OnboardingTour.tsx b/src/components/OnboardingTour.tsx new file mode 100644 index 0000000..174dd84 --- /dev/null +++ b/src/components/OnboardingTour.tsx @@ -0,0 +1,141 @@ +// First-visit onboarding tour. Highlights five tabs in sequence with a +// minimal popover. localStorage flag fm.canvas.tour.seen prevents replay. +// No external dep (driver.js is 18 KB; this is ~3 KB and matches our +// design language). + +import { useEffect, useRef, useState } from "react"; + +interface TourStep { + selector: string; + title: string; + body: string; +} + +const STEPS: TourStep[] = [ + { + selector: '.tab[aria-selected][role="tab"]:nth-of-type(1)', + title: "Mission Control", + body: "Live work-items, the process graph, and the inspector. Start here every morning to see what's running.", + }, + { + selector: ".tab:nth-of-type(2)", + title: "Approvals", + body: "Anything waiting on your signature. The badge counts unread; the topbar bell aggregates everything that needs you.", + }, + { + selector: ".tab:nth-of-type(4)", + title: "Process Studio", + body: "Describe a new process in plain English, pick the steps, add the form fields and rules, publish to EA2. Five guided phases.", + }, + { + selector: ".tab:nth-of-type(5)", + title: "Team Chat", + body: "Text a manager or teammate without booting another app. Replaces the 'quick question' Teams ping for procurement and approvals.", + }, + { + selector: ".tab:nth-of-type(6)", + title: "Command Assistant", + body: "Ask in plain language: 'start laptop procurement for store 204', 'find INC-4427', 'remember the spending cap is 5000'.", + }, +]; + +const LS_KEY = "fm.canvas.tour.seen"; + +export default function OnboardingTour() { + const [index, setIndex] = useState(null); + const [anchor, setAnchor] = useState(null); + const tipRef = useRef(null); + + useEffect(() => { + if (typeof window === "undefined") return; + if (localStorage.getItem(LS_KEY)) return; + const t = window.setTimeout(() => setIndex(0), 800); + return () => window.clearTimeout(t); + }, []); + + useEffect(() => { + if (index === null) return; + const step = STEPS[index]; + const el = document.querySelector(step.selector) as HTMLElement | null; + if (!el) { + setAnchor(null); + return; + } + el.scrollIntoView({ block: "nearest", inline: "nearest" }); + const measure = () => setAnchor(el.getBoundingClientRect()); + measure(); + window.addEventListener("resize", measure); + return () => window.removeEventListener("resize", measure); + }, [index]); + + useEffect(() => { + if (index === null) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") finish(); + if (e.key === "ArrowRight" || e.key === "Enter") next(); + if (e.key === "ArrowLeft") prev(); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }); + + if (index === null) return null; + + function finish() { + try { localStorage.setItem(LS_KEY, String(Date.now())); } catch { /* quota */ } + setIndex(null); + } + function next() { + if (index === null) return; + if (index >= STEPS.length - 1) finish(); + else setIndex(index + 1); + } + function prev() { + if (index === null) return; + if (index > 0) setIndex(index - 1); + } + + const step = STEPS[index]; + const tipTop = anchor ? anchor.bottom + 12 : 80; + const tipLeft = anchor ? Math.max(16, Math.min(window.innerWidth - 360, anchor.left)) : 16; + + return ( + <> +
+ {anchor && ( +
+ )} +
+
+ {index + 1} / {STEPS.length} + +
+

{step.title}

+

{step.body}

+
+ {index > 0 && ( + + )} + +
+
+ + ); +} diff --git a/src/index.css b/src/index.css index 9543eb4..a58afa2 100644 --- a/src/index.css +++ b/src/index.css @@ -2377,3 +2377,58 @@ select.studio-input { background: var(--bp-paper); } color: var(--bp-navy); } .cmd-try-hint { color: var(--bp-muted); font-size: 11px; } + +/* Onboarding tour */ +.tour-backdrop { + position: fixed; + inset: 0; + background: color-mix(in srgb, var(--bp-navy) 50%, transparent); + z-index: 9000; + cursor: pointer; +} +.tour-spotlight { + position: fixed; + z-index: 9001; + border: 2px solid var(--bp-amber, #d97a00); + background: transparent; + box-shadow: 0 0 0 9999px color-mix(in srgb, var(--bp-navy) 50%, transparent); + pointer-events: none; + transition: top 180ms ease, left 180ms ease, width 180ms ease, height 180ms ease; +} +.tour-tip { + position: fixed; + z-index: 9002; + width: 320px; + background: var(--bp-paper, #fdfaf2); + border: 1px solid var(--bp-navy, #1a2740); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 8px; + box-shadow: 0 8px 24px color-mix(in srgb, var(--bp-navy) 30%, transparent); +} +.tour-tip-head { display: flex; align-items: center; justify-content: space-between; } +.tour-tip-step { + font-family: var(--bp-mono); + font-size: 10px; + letter-spacing: 0.08em; + color: var(--bp-muted); +} +.tour-tip-skip { + background: transparent; + border: 0; + font-family: var(--bp-mono); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--bp-muted); + cursor: pointer; + padding: 0; +} +.tour-tip-skip:hover { color: var(--bp-navy); text-decoration: underline; } +.tour-tip-title { margin: 0; font-size: 14px; font-weight: 700; color: var(--bp-navy); } +.tour-tip-body { margin: 0; font-size: 12px; line-height: 1.5; color: var(--bp-navy); } +.tour-tip-actions { display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px; } +.btn-sm { font-size: 11px; padding: 4px 10px; } +[data-theme="dark"] .tour-tip { background: #1a2333; border-color: #d97a00; color: #f5e7c8; } +[data-theme="dark"] .tour-tip-title, [data-theme="dark"] .tour-tip-body { color: #f5e7c8; }