feat(onboarding): first-visit 5-step highlight tour
build-and-publish / test (pull_request) Has been cancelled
build-and-publish / image (pull_request) Has been cancelled

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:
canvas-bot
2026-06-15 04:23:15 +04:00
parent 1363ade484
commit 97e1dcc3a9
3 changed files with 198 additions and 0 deletions
+2
View File
@@ -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() {
<CommandBar />
<Console />
<Toaster />
{isAuthed && scene !== "login" && scene !== "sso-callback" && <OnboardingTour />}
</div>
);
}
+141
View File
@@ -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>
</>
);
}
+55
View File
@@ -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; }