Skip to content

Commit 58411ac

Browse files
authored
Merge pull request #155 from nieao/feat/onboarding-overlay
feat(dashboard): first-visit onboarding overlay
2 parents 57a25ed + 9dfcce7 commit 58411ac

8 files changed

Lines changed: 483 additions & 0 deletions

File tree

understand-anything-plugin/packages/dashboard/src/App.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,20 @@ const PathFinderModal = lazy(() => import("./components/PathFinderModal"));
3232
const KeyboardShortcutsHelp = lazy(
3333
() => import("./components/KeyboardShortcutsHelp"),
3434
);
35+
const OnboardingOverlay = lazy(() => import("./components/OnboardingOverlay"));
3536

3637
const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === "true";
3738
const SESSION_TOKEN_KEY = "understand-anything-token";
39+
const ONBOARDING_DISMISSED_KEY = "ua-onboarding-dismissed-v1";
3840
type SidebarTab = "info" | "files";
3941

42+
function shouldShowOnboarding(): boolean {
43+
if (typeof window === "undefined") return false;
44+
const params = new URLSearchParams(window.location.search);
45+
if (params.get("onboard") === "force") return true;
46+
return window.localStorage.getItem(ONBOARDING_DISMISSED_KEY) !== "1";
47+
}
48+
4049
/** Resolve data file URL — in demo mode, use env var URLs; otherwise use local paths with token. */
4150
function dataUrl(fileName: string, token: string | null): string {
4251
if (DEMO_MODE) {
@@ -235,6 +244,13 @@ function DashboardContent({
235244
const toggleShowFunctionsInClassView = useDashboardStore((s) => s.toggleShowFunctionsInClassView);
236245
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
237246
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("info");
247+
const [showOnboarding, setShowOnboarding] = useState(shouldShowOnboarding);
248+
const dismissOnboarding = useCallback((remember: boolean) => {
249+
if (remember && typeof window !== "undefined") {
250+
window.localStorage.setItem(ONBOARDING_DISMISSED_KEY, "1");
251+
}
252+
setShowOnboarding(false);
253+
}, []);
238254
const viewMode = useDashboardStore((s) => s.viewMode);
239255
const setViewMode = useDashboardStore((s) => s.setViewMode);
240256
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
@@ -683,6 +699,13 @@ function DashboardContent({
683699
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
684700
</Suspense>
685701
)}
702+
703+
{/* First-visit onboarding overlay — only mounted when needed so its chunk is lazy-loaded on demand. */}
704+
{showOnboarding && (
705+
<Suspense fallback={null}>
706+
<OnboardingOverlay onDismiss={dismissOnboarding} />
707+
</Suspense>
708+
)}
686709
</div>
687710
);
688711
}
Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
import { useEffect, useState } from "react";
2+
import { useI18n } from "../contexts/I18nContext";
3+
4+
/**
5+
* First-visit onboarding overlay (controlled).
6+
*
7+
* Parent owns the visibility + persistence state (see App.tsx). This component
8+
* only renders the modal and reports the user's intent via onDismiss:
9+
* - onDismiss(true) → "Skip" / Finish — parent should persist.
10+
* - onDismiss(false) → backdrop click / Escape — parent should close without persisting.
11+
*
12+
* Force-show is handled by the parent (see `shouldShowOnboarding` in App.tsx).
13+
*/
14+
15+
interface Props {
16+
onDismiss: (remember: boolean) => void;
17+
}
18+
19+
const TITLE_ID = "ua-onboarding-title";
20+
21+
export default function OnboardingOverlay({ onDismiss }: Props) {
22+
const { t } = useI18n();
23+
const STEPS = t.onboarding.steps;
24+
const [stepIdx, setStepIdx] = useState(0);
25+
26+
// Capture-phase Escape handler — runs before the global keydown chain so we
27+
// can stopPropagation() and prevent it from also firing.
28+
useEffect(() => {
29+
const handler = (e: KeyboardEvent) => {
30+
if (e.key === "Escape") {
31+
e.stopPropagation();
32+
onDismiss(false);
33+
}
34+
};
35+
document.addEventListener("keydown", handler, true);
36+
return () => document.removeEventListener("keydown", handler, true);
37+
}, [onDismiss]);
38+
39+
const isFirst = stepIdx === 0;
40+
const isLast = stepIdx === STEPS.length - 1;
41+
const step = STEPS[stepIdx];
42+
43+
return (
44+
<div
45+
style={overlayStyle}
46+
onClick={(e) => {
47+
if (e.target === e.currentTarget) onDismiss(false);
48+
}}
49+
>
50+
<style>{KEYFRAMES}</style>
51+
<div
52+
role="dialog"
53+
aria-modal="true"
54+
aria-labelledby={TITLE_ID}
55+
style={cardStyle}
56+
>
57+
<div style={tagStyle}>
58+
<span style={numStyle}>0{stepIdx + 1}</span>
59+
<span> / 0{STEPS.length}</span>
60+
<span style={dotStyle} />
61+
<span>{t.onboarding.header}</span>
62+
</div>
63+
64+
<h2 id={TITLE_ID} style={titleStyle}>
65+
{step.title}
66+
</h2>
67+
<p style={bodyStyle}>{step.body}</p>
68+
{step.hint && (
69+
<blockquote style={hintStyle}>
70+
<span style={{ color: "var(--color-accent)", marginRight: 8 }}>·</span>
71+
{step.hint}
72+
</blockquote>
73+
)}
74+
75+
<div style={progressTrackStyle}>
76+
{STEPS.map((_, i) => (
77+
<div
78+
key={i}
79+
style={{
80+
...dotProgressStyle,
81+
background:
82+
i === stepIdx
83+
? "var(--color-accent)"
84+
: "var(--color-border-medium)",
85+
width: i === stepIdx ? 28 : 6,
86+
}}
87+
/>
88+
))}
89+
</div>
90+
91+
<div style={btnRowStyle}>
92+
<button
93+
type="button"
94+
onClick={() => onDismiss(true)}
95+
style={{ ...btnStyle, ...btnGhostStyle }}
96+
>
97+
{t.onboarding.skipForever}
98+
</button>
99+
<div style={{ flex: 1 }} />
100+
{!isFirst && (
101+
<button
102+
type="button"
103+
onClick={() => setStepIdx(stepIdx - 1)}
104+
style={{ ...btnStyle, ...btnGhostStyle }}
105+
>
106+
{t.onboarding.prev}
107+
</button>
108+
)}
109+
{!isLast ? (
110+
<button
111+
type="button"
112+
onClick={() => setStepIdx(stepIdx + 1)}
113+
style={{ ...btnStyle, ...btnPrimaryStyle }}
114+
>
115+
{t.onboarding.next}
116+
</button>
117+
) : (
118+
<button
119+
type="button"
120+
onClick={() => onDismiss(true)}
121+
style={{ ...btnStyle, ...btnPrimaryStyle }}
122+
>
123+
{t.onboarding.finish}
124+
</button>
125+
)}
126+
</div>
127+
</div>
128+
</div>
129+
);
130+
}
131+
132+
const KEYFRAMES = `@keyframes ua-fade-in { from { opacity: 0 } to { opacity: 1 } }`;
133+
134+
const overlayStyle: React.CSSProperties = {
135+
position: "fixed",
136+
inset: 0,
137+
background: "rgba(0, 0, 0, 0.78)",
138+
backdropFilter: "blur(6px)",
139+
zIndex: 9999,
140+
display: "flex",
141+
alignItems: "center",
142+
justifyContent: "center",
143+
padding: 16,
144+
fontFamily: "var(--font-sans)",
145+
animation: "ua-fade-in 0.4s cubic-bezier(0.22, 1, 0.36, 1)",
146+
};
147+
148+
const cardStyle: React.CSSProperties = {
149+
background: "var(--color-elevated)",
150+
color: "var(--color-text-primary)",
151+
maxWidth: 580,
152+
width: "100%",
153+
padding: "48px 48px 36px",
154+
border: "1px solid var(--color-border-subtle)",
155+
borderTop: "2px solid var(--color-accent)",
156+
position: "relative",
157+
};
158+
159+
const tagStyle: React.CSSProperties = {
160+
fontSize: "0.72rem",
161+
letterSpacing: "0.3em",
162+
color: "var(--color-text-muted)",
163+
textTransform: "uppercase",
164+
marginBottom: 24,
165+
display: "flex",
166+
alignItems: "center",
167+
flexWrap: "wrap",
168+
gap: 4,
169+
};
170+
171+
const numStyle: React.CSSProperties = {
172+
fontFamily: "var(--font-heading)",
173+
color: "var(--color-accent)",
174+
fontSize: "0.9rem",
175+
letterSpacing: "0.1em",
176+
marginRight: 4,
177+
};
178+
179+
const dotStyle: React.CSSProperties = {
180+
width: 4,
181+
height: 4,
182+
background: "var(--color-accent)",
183+
borderRadius: "50%",
184+
margin: "0 12px",
185+
};
186+
187+
const titleStyle: React.CSSProperties = {
188+
fontFamily: "var(--font-heading)",
189+
fontSize: "1.7rem",
190+
fontWeight: 400,
191+
letterSpacing: "0.02em",
192+
lineHeight: 1.3,
193+
marginBottom: 16,
194+
color: "var(--color-text-primary)",
195+
};
196+
197+
const bodyStyle: React.CSSProperties = {
198+
fontSize: "0.98rem",
199+
lineHeight: 1.7,
200+
color: "var(--color-text-secondary)",
201+
marginBottom: 0,
202+
};
203+
204+
const hintStyle: React.CSSProperties = {
205+
margin: "20px 0 0",
206+
padding: "12px 18px",
207+
borderLeft: "2px solid var(--color-border-medium)",
208+
background: "var(--color-accent-overlay-bg)",
209+
fontSize: "0.86rem",
210+
color: "var(--color-accent)",
211+
fontStyle: "italic",
212+
};
213+
214+
const progressTrackStyle: React.CSSProperties = {
215+
display: "flex",
216+
gap: 6,
217+
marginTop: 36,
218+
marginBottom: 28,
219+
};
220+
221+
const dotProgressStyle: React.CSSProperties = {
222+
height: 4,
223+
borderRadius: 2,
224+
transition: "width 0.5s cubic-bezier(0.22, 1, 0.36, 1), background 0.3s",
225+
};
226+
227+
const btnRowStyle: React.CSSProperties = {
228+
display: "flex",
229+
alignItems: "center",
230+
gap: 10,
231+
};
232+
233+
const btnStyle: React.CSSProperties = {
234+
padding: "10px 22px",
235+
fontSize: "0.82rem",
236+
letterSpacing: "0.12em",
237+
textTransform: "uppercase",
238+
border: "1px solid",
239+
cursor: "pointer",
240+
fontFamily: "inherit",
241+
transition: "all 0.3s cubic-bezier(0.22, 1, 0.36, 1)",
242+
fontWeight: 400,
243+
};
244+
245+
const btnGhostStyle: React.CSSProperties = {
246+
background: "transparent",
247+
borderColor: "var(--color-border-medium)",
248+
color: "var(--color-text-muted)",
249+
};
250+
251+
const btnPrimaryStyle: React.CSSProperties = {
252+
background: "var(--color-accent)",
253+
borderColor: "var(--color-accent)",
254+
color: "var(--color-root)",
255+
fontWeight: 500,
256+
};

understand-anything-plugin/packages/dashboard/src/locales/en.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,40 @@ export const en = {
267267
pathFinder: {
268268
title: "Find path between nodes (P)",
269269
},
270+
onboarding: {
271+
header: "UNDERSTAND-ANYTHING · GET STARTED",
272+
skipForever: "Don't show again",
273+
prev: "Previous",
274+
next: "Next",
275+
finish: "Start exploring",
276+
steps: [
277+
{
278+
title: "Welcome to the knowledge graph",
279+
body: "The dots and lines you see are entities and relations Understand-Anything extracted from this project. A node can be a file, class, or function from the code — or a concept, entity, or claim from a knowledge wiki.",
280+
hint: "Five steps to cover the core operations",
281+
},
282+
{
283+
title: "Three views at the top",
284+
body: "Overview shows the big picture (force-directed). Learn follows a preset learning path. Deep Dive shows type and complexity stats. Each view answers a different question.",
285+
hint: "Decide what you're asking before you switch",
286+
},
287+
{
288+
title: "Search + click a node",
289+
body: "The top search box fuzzy-matches node name / summary / tags. Click any node and the right panel opens with summary, neighbors, and Open Article.",
290+
hint: "Search centers and highlights; clicking a node highlights its edges",
291+
},
292+
{
293+
title: "Layer switch + Project Tour",
294+
body: "The layer tabs next to All filter the graph to one category, sourced from index.md. Project Tour on the right walks you through the editor's preset sequence.",
295+
hint: "Use Layer when nodes are too dense; start Tour when you have no entry point",
296+
},
297+
{
298+
title: "More hidden features",
299+
body: "The top bar also has Filter (by type / complexity), Export (export the graph), Path (find a path between two nodes), and Theme. Press Shift + ? for the full keyboard shortcuts.",
300+
hint: "Expand them when you need them — no need to memorize all at once",
301+
},
302+
],
303+
},
270304
};
271305

272306
export default en;

understand-anything-plugin/packages/dashboard/src/locales/ja.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,40 @@ export const ja = {
267267
pathFinder: {
268268
title: "ノード間のパスを検索 (P)",
269269
},
270+
onboarding: {
271+
header: "UNDERSTAND-ANYTHING · はじめに",
272+
skipForever: "次回から表示しない",
273+
prev: "前へ",
274+
next: "次へ",
275+
finish: "探索を始める",
276+
steps: [
277+
{
278+
title: "知識グラフへようこそ",
279+
body: "表示されているノードとエッジは、Understand-Anything がこのプロジェクトから抽出したエンティティと関係です。ノードはコード側のファイル・クラス・関数のこともあれば、知識 wiki 側の概念・エンティティ・記述のこともあります。",
280+
hint: "5 ステップで主要な操作を確認します",
281+
},
282+
{
283+
title: "上部の 3 つのビュー",
284+
body: "Overview は全体像(力学的レイアウト)、Learn はあらかじめ用意された学習パス、Deep Dive はタイプ / 複雑度の統計を表示します。それぞれ異なる問いに答えるためのビューです。",
285+
hint: "切り替える前に、何を知りたいかを明確に",
286+
},
287+
{
288+
title: "検索 + ノードクリック",
289+
body: "上部の検索ボックスはノード名 / summary / タグをあいまい検索します。任意のノードをクリックすると、右側のパネルに summary、隣接ノード、Open Article ボタンが表示されます。",
290+
hint: "検索はノードを中央寄せ・ハイライト、クリックは隣接エッジをハイライトします",
291+
},
292+
{
293+
title: "Layer 切替 + Project Tour",
294+
body: "上部 All の隣にある layer タブは index.md に基づいて 1 つのカテゴリだけを表示します。右側の Project Tour は編集者が用意した順序でガイドします。",
295+
hint: "ノードが多すぎるときは Layer、入り口がわからないときは Tour",
296+
},
297+
{
298+
title: "その他の隠れた機能",
299+
body: "上部バーには Filter(タイプ / 複雑度で絞り込み)、Export(グラフを書き出す)、Path(2 つのノード間のパスを検索)、Theme(テーマ切替)もあります。Shift + ? で全キーボードショートカットを確認できます。",
300+
hint: "必要になったときに開けば十分。一度に覚える必要はありません",
301+
},
302+
],
303+
},
270304
};
271305

272306
export default ja;

0 commit comments

Comments
 (0)