feat(onboarding): first-visit 5-step highlight tour
OnboardingTour.tsx — minimal Driver.js-style spotlight + tip without an external dep (driver.js is 18KB; this is ~3KB and matches the blueprint design language). Fires once per browser (localStorage flag fm.canvas.tour.seen). Five steps: 1. Mission Control — start every morning here 2. Approvals — anything waiting on you 3. Process Studio — describe → publish in five phases 4. Team Chat — replaces the 'quick Teams ping' 5. Command Assistant — plain-language actions Keyboard nav: Enter / → next, ← back, Esc skip. Click backdrop also skips. Only mounts when authed and not on login/sso-callback. Dark theme colours included. 31/31 vitest pass.
This commit is contained in:
@@ -18,6 +18,7 @@ import Documents from "./scenes/Documents";
|
|||||||
import CommandBar from "./components/CommandBar";
|
import CommandBar from "./components/CommandBar";
|
||||||
import Toaster from "./components/Toaster";
|
import Toaster from "./components/Toaster";
|
||||||
import Console from "./components/Console";
|
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 { Cmd, Home, Layers, HistoryIcon, Pulse, Refresh, Branch, Cog, User, Sun, Moon, Bot, Bell } from "./components/icons";
|
||||||
import { useBackendHealth } from "./lib/useBackendHealth";
|
import { useBackendHealth } from "./lib/useBackendHealth";
|
||||||
|
|
||||||
@@ -272,6 +273,7 @@ export default function App() {
|
|||||||
<CommandBar />
|
<CommandBar />
|
||||||
<Console />
|
<Console />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
{isAuthed && scene !== "login" && scene !== "sso-callback" && <OnboardingTour />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<number | null>(null);
|
||||||
|
const [anchor, setAnchor] = useState<DOMRect | null>(null);
|
||||||
|
const tipRef = useRef<HTMLDivElement>(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 (
|
||||||
|
<>
|
||||||
|
<div className="tour-backdrop" onClick={finish} aria-hidden />
|
||||||
|
{anchor && (
|
||||||
|
<div
|
||||||
|
className="tour-spotlight"
|
||||||
|
style={{
|
||||||
|
top: anchor.top - 4,
|
||||||
|
left: anchor.left - 4,
|
||||||
|
width: anchor.width + 8,
|
||||||
|
height: anchor.height + 8,
|
||||||
|
}}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={tipRef}
|
||||||
|
className="tour-tip"
|
||||||
|
role="dialog"
|
||||||
|
aria-label={step.title}
|
||||||
|
style={{ top: tipTop, left: tipLeft }}
|
||||||
|
>
|
||||||
|
<div className="tour-tip-head">
|
||||||
|
<span className="tour-tip-step">{index + 1} / {STEPS.length}</span>
|
||||||
|
<button className="tour-tip-skip" onClick={finish} aria-label="Skip tour">Skip</button>
|
||||||
|
</div>
|
||||||
|
<h3 className="tour-tip-title">{step.title}</h3>
|
||||||
|
<p className="tour-tip-body">{step.body}</p>
|
||||||
|
<div className="tour-tip-actions">
|
||||||
|
{index > 0 && (
|
||||||
|
<button className="btn btn-ghost btn-sm" onClick={prev}>Back</button>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={next}>
|
||||||
|
{index >= STEPS.length - 1 ? "Done" : "Next →"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2377,3 +2377,58 @@ select.studio-input { background: var(--bp-paper); }
|
|||||||
color: var(--bp-navy);
|
color: var(--bp-navy);
|
||||||
}
|
}
|
||||||
.cmd-try-hint { color: var(--bp-muted); font-size: 11px; }
|
.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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user