Merge pull request #155 from nieao/feat/onboarding-overlay

feat(dashboard): first-visit onboarding overlay
This commit is contained in:
Yuxiang Lin
2026-05-21 11:09:34 +08:00
committed by GitHub
8 changed files with 483 additions and 0 deletions
@@ -32,11 +32,20 @@ const PathFinderModal = lazy(() => import("./components/PathFinderModal"));
const KeyboardShortcutsHelp = lazy(
() => import("./components/KeyboardShortcutsHelp"),
);
const OnboardingOverlay = lazy(() => import("./components/OnboardingOverlay"));
const DEMO_MODE = import.meta.env.VITE_DEMO_MODE === "true";
const SESSION_TOKEN_KEY = "understand-anything-token";
const ONBOARDING_DISMISSED_KEY = "ua-onboarding-dismissed-v1";
type SidebarTab = "info" | "files";
function shouldShowOnboarding(): boolean {
if (typeof window === "undefined") return false;
const params = new URLSearchParams(window.location.search);
if (params.get("onboard") === "force") return true;
return window.localStorage.getItem(ONBOARDING_DISMISSED_KEY) !== "1";
}
/** Resolve data file URL — in demo mode, use env var URLs; otherwise use local paths with token. */
function dataUrl(fileName: string, token: string | null): string {
if (DEMO_MODE) {
@@ -235,6 +244,13 @@ function DashboardContent({
const toggleShowFunctionsInClassView = useDashboardStore((s) => s.toggleShowFunctionsInClassView);
const [showKeyboardHelp, setShowKeyboardHelp] = useState(false);
const [sidebarTab, setSidebarTab] = useState<SidebarTab>("info");
const [showOnboarding, setShowOnboarding] = useState(shouldShowOnboarding);
const dismissOnboarding = useCallback((remember: boolean) => {
if (remember && typeof window !== "undefined") {
window.localStorage.setItem(ONBOARDING_DISMISSED_KEY, "1");
}
setShowOnboarding(false);
}, []);
const viewMode = useDashboardStore((s) => s.viewMode);
const setViewMode = useDashboardStore((s) => s.setViewMode);
const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph);
@@ -683,6 +699,13 @@ function DashboardContent({
<PathFinderModal isOpen={pathFinderOpen} onClose={togglePathFinder} />
</Suspense>
)}
{/* First-visit onboarding overlay — only mounted when needed so its chunk is lazy-loaded on demand. */}
{showOnboarding && (
<Suspense fallback={null}>
<OnboardingOverlay onDismiss={dismissOnboarding} />
</Suspense>
)}
</div>
);
}
@@ -0,0 +1,256 @@
import { useEffect, useState } from "react";
import { useI18n } from "../contexts/I18nContext";
/**
* First-visit onboarding overlay (controlled).
*
* Parent owns the visibility + persistence state (see App.tsx). This component
* only renders the modal and reports the user's intent via onDismiss:
* - onDismiss(true) → "Skip" / Finish — parent should persist.
* - onDismiss(false) → backdrop click / Escape — parent should close without persisting.
*
* Force-show is handled by the parent (see `shouldShowOnboarding` in App.tsx).
*/
interface Props {
onDismiss: (remember: boolean) => void;
}
const TITLE_ID = "ua-onboarding-title";
export default function OnboardingOverlay({ onDismiss }: Props) {
const { t } = useI18n();
const STEPS = t.onboarding.steps;
const [stepIdx, setStepIdx] = useState(0);
// Capture-phase Escape handler — runs before the global keydown chain so we
// can stopPropagation() and prevent it from also firing.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onDismiss(false);
}
};
document.addEventListener("keydown", handler, true);
return () => document.removeEventListener("keydown", handler, true);
}, [onDismiss]);
const isFirst = stepIdx === 0;
const isLast = stepIdx === STEPS.length - 1;
const step = STEPS[stepIdx];
return (
<div
style={overlayStyle}
onClick={(e) => {
if (e.target === e.currentTarget) onDismiss(false);
}}
>
<style>{KEYFRAMES}</style>
<div
role="dialog"
aria-modal="true"
aria-labelledby={TITLE_ID}
style={cardStyle}
>
<div style={tagStyle}>
<span style={numStyle}>0{stepIdx + 1}</span>
<span> / 0{STEPS.length}</span>
<span style={dotStyle} />
<span>{t.onboarding.header}</span>
</div>
<h2 id={TITLE_ID} style={titleStyle}>
{step.title}
</h2>
<p style={bodyStyle}>{step.body}</p>
{step.hint && (
<blockquote style={hintStyle}>
<span style={{ color: "var(--color-accent)", marginRight: 8 }}>·</span>
{step.hint}
</blockquote>
)}
<div style={progressTrackStyle}>
{STEPS.map((_, i) => (
<div
key={i}
style={{
...dotProgressStyle,
background:
i === stepIdx
? "var(--color-accent)"
: "var(--color-border-medium)",
width: i === stepIdx ? 28 : 6,
}}
/>
))}
</div>
<div style={btnRowStyle}>
<button
type="button"
onClick={() => onDismiss(true)}
style={{ ...btnStyle, ...btnGhostStyle }}
>
{t.onboarding.skipForever}
</button>
<div style={{ flex: 1 }} />
{!isFirst && (
<button
type="button"
onClick={() => setStepIdx(stepIdx - 1)}
style={{ ...btnStyle, ...btnGhostStyle }}
>
{t.onboarding.prev}
</button>
)}
{!isLast ? (
<button
type="button"
onClick={() => setStepIdx(stepIdx + 1)}
style={{ ...btnStyle, ...btnPrimaryStyle }}
>
{t.onboarding.next}
</button>
) : (
<button
type="button"
onClick={() => onDismiss(true)}
style={{ ...btnStyle, ...btnPrimaryStyle }}
>
{t.onboarding.finish}
</button>
)}
</div>
</div>
</div>
);
}
const KEYFRAMES = `@keyframes ua-fade-in { from { opacity: 0 } to { opacity: 1 } }`;
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(0, 0, 0, 0.78)",
backdropFilter: "blur(6px)",
zIndex: 9999,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
fontFamily: "var(--font-sans)",
animation: "ua-fade-in 0.4s cubic-bezier(0.22, 1, 0.36, 1)",
};
const cardStyle: React.CSSProperties = {
background: "var(--color-elevated)",
color: "var(--color-text-primary)",
maxWidth: 580,
width: "100%",
padding: "48px 48px 36px",
border: "1px solid var(--color-border-subtle)",
borderTop: "2px solid var(--color-accent)",
position: "relative",
};
const tagStyle: React.CSSProperties = {
fontSize: "0.72rem",
letterSpacing: "0.3em",
color: "var(--color-text-muted)",
textTransform: "uppercase",
marginBottom: 24,
display: "flex",
alignItems: "center",
flexWrap: "wrap",
gap: 4,
};
const numStyle: React.CSSProperties = {
fontFamily: "var(--font-heading)",
color: "var(--color-accent)",
fontSize: "0.9rem",
letterSpacing: "0.1em",
marginRight: 4,
};
const dotStyle: React.CSSProperties = {
width: 4,
height: 4,
background: "var(--color-accent)",
borderRadius: "50%",
margin: "0 12px",
};
const titleStyle: React.CSSProperties = {
fontFamily: "var(--font-heading)",
fontSize: "1.7rem",
fontWeight: 400,
letterSpacing: "0.02em",
lineHeight: 1.3,
marginBottom: 16,
color: "var(--color-text-primary)",
};
const bodyStyle: React.CSSProperties = {
fontSize: "0.98rem",
lineHeight: 1.7,
color: "var(--color-text-secondary)",
marginBottom: 0,
};
const hintStyle: React.CSSProperties = {
margin: "20px 0 0",
padding: "12px 18px",
borderLeft: "2px solid var(--color-border-medium)",
background: "var(--color-accent-overlay-bg)",
fontSize: "0.86rem",
color: "var(--color-accent)",
fontStyle: "italic",
};
const progressTrackStyle: React.CSSProperties = {
display: "flex",
gap: 6,
marginTop: 36,
marginBottom: 28,
};
const dotProgressStyle: React.CSSProperties = {
height: 4,
borderRadius: 2,
transition: "width 0.5s cubic-bezier(0.22, 1, 0.36, 1), background 0.3s",
};
const btnRowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
gap: 10,
};
const btnStyle: React.CSSProperties = {
padding: "10px 22px",
fontSize: "0.82rem",
letterSpacing: "0.12em",
textTransform: "uppercase",
border: "1px solid",
cursor: "pointer",
fontFamily: "inherit",
transition: "all 0.3s cubic-bezier(0.22, 1, 0.36, 1)",
fontWeight: 400,
};
const btnGhostStyle: React.CSSProperties = {
background: "transparent",
borderColor: "var(--color-border-medium)",
color: "var(--color-text-muted)",
};
const btnPrimaryStyle: React.CSSProperties = {
background: "var(--color-accent)",
borderColor: "var(--color-accent)",
color: "var(--color-root)",
fontWeight: 500,
};
@@ -267,6 +267,40 @@ export const en = {
pathFinder: {
title: "Find path between nodes (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · GET STARTED",
skipForever: "Don't show again",
prev: "Previous",
next: "Next",
finish: "Start exploring",
steps: [
{
title: "Welcome to the knowledge graph",
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.",
hint: "Five steps to cover the core operations",
},
{
title: "Three views at the top",
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.",
hint: "Decide what you're asking before you switch",
},
{
title: "Search + click a node",
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.",
hint: "Search centers and highlights; clicking a node highlights its edges",
},
{
title: "Layer switch + Project Tour",
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.",
hint: "Use Layer when nodes are too dense; start Tour when you have no entry point",
},
{
title: "More hidden features",
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.",
hint: "Expand them when you need them — no need to memorize all at once",
},
],
},
};
export default en;
@@ -267,6 +267,40 @@ export const ja = {
pathFinder: {
title: "ノード間のパスを検索 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · はじめに",
skipForever: "次回から表示しない",
prev: "前へ",
next: "次へ",
finish: "探索を始める",
steps: [
{
title: "知識グラフへようこそ",
body: "表示されているノードとエッジは、Understand-Anything がこのプロジェクトから抽出したエンティティと関係です。ノードはコード側のファイル・クラス・関数のこともあれば、知識 wiki 側の概念・エンティティ・記述のこともあります。",
hint: "5 ステップで主要な操作を確認します",
},
{
title: "上部の 3 つのビュー",
body: "Overview は全体像(力学的レイアウト)、Learn はあらかじめ用意された学習パス、Deep Dive はタイプ / 複雑度の統計を表示します。それぞれ異なる問いに答えるためのビューです。",
hint: "切り替える前に、何を知りたいかを明確に",
},
{
title: "検索 + ノードクリック",
body: "上部の検索ボックスはノード名 / summary / タグをあいまい検索します。任意のノードをクリックすると、右側のパネルに summary、隣接ノード、Open Article ボタンが表示されます。",
hint: "検索はノードを中央寄せ・ハイライト、クリックは隣接エッジをハイライトします",
},
{
title: "Layer 切替 + Project Tour",
body: "上部 All の隣にある layer タブは index.md に基づいて 1 つのカテゴリだけを表示します。右側の Project Tour は編集者が用意した順序でガイドします。",
hint: "ノードが多すぎるときは Layer、入り口がわからないときは Tour",
},
{
title: "その他の隠れた機能",
body: "上部バーには Filter(タイプ / 複雑度で絞り込み)、Export(グラフを書き出す)、Path(2 つのノード間のパスを検索)、Theme(テーマ切替)もあります。Shift + ? で全キーボードショートカットを確認できます。",
hint: "必要になったときに開けば十分。一度に覚える必要はありません",
},
],
},
};
export default ja;
@@ -267,6 +267,40 @@ edgeLabels: {
pathFinder: {
title: "노드 간 경로 찾기 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · 시작하기",
skipForever: "다시 보지 않기",
prev: "이전",
next: "다음",
finish: "탐색 시작",
steps: [
{
title: "지식 그래프에 오신 것을 환영합니다",
body: "보이는 점과 선은 Understand-Anything이 이 프로젝트에서 추출한 엔티티와 관계입니다. 노드는 코드 쪽의 파일·클래스·함수일 수도 있고, 지식 위키 쪽의 개념·엔티티·진술일 수도 있습니다.",
hint: "5단계로 핵심 조작을 살펴봅니다",
},
{
title: "상단의 세 가지 뷰",
body: "Overview는 전체 모습(포스 디렉티드), Learn은 미리 정의된 학습 경로, Deep Dive는 타입 / 복잡도 통계를 보여줍니다. 각 뷰는 서로 다른 질문에 답합니다.",
hint: "전환하기 전에 무엇을 묻고 싶은지 정하세요",
},
{
title: "검색 + 노드 클릭",
body: "상단 검색창은 노드 이름 / summary / 태그를 퍼지 매칭합니다. 노드를 클릭하면 오른쪽 패널에 summary, 이웃 목록, Open Article 버튼이 나타납니다.",
hint: "검색은 노드를 중앙 정렬·강조하고, 클릭은 인접 엣지를 강조합니다",
},
{
title: "Layer 전환 + Project Tour",
body: "상단 All 옆의 layer 탭은 index.md를 기반으로 한 카테고리만 표시합니다. 오른쪽의 Project Tour는 편집자가 설정한 순서대로 안내합니다.",
hint: "노드가 너무 빽빽하면 Layer, 시작점이 없으면 Tour를 사용하세요",
},
{
title: "숨겨진 추가 기능",
body: "상단 바에는 Filter(타입 / 복잡도로 필터링), Export(그래프 내보내기), Path(두 노드 사이 경로 찾기), Theme(테마 전환)도 있습니다. Shift + ?를 누르면 전체 키보드 단축키를 볼 수 있습니다.",
hint: "필요할 때 펼쳐 보면 됩니다. 한 번에 다 외울 필요는 없습니다",
},
],
},
};
export default ko;
@@ -267,6 +267,40 @@ export const ru = {
pathFinder: {
title: "Найти путь между узлами (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · НАЧАЛО РАБОТЫ",
skipForever: "Больше не показывать",
prev: "Назад",
next: "Далее",
finish: "Начать исследование",
steps: [
{
title: "Добро пожаловать в граф знаний",
body: "Точки и линии — это сущности и связи, извлечённые Understand-Anything из этого проекта. Узлом может быть файл, класс или функция из кода — либо концепция, сущность или утверждение из вики знаний.",
hint: "Пять шагов охватят основные операции",
},
{
title: "Три вида сверху",
body: "Overview показывает общую картину (force-directed). Learn ведёт по заранее заданному учебному пути. Deep Dive показывает статистику по типам и сложности. Каждый вид отвечает на свой вопрос.",
hint: "Перед переключением определитесь, о чём вы спрашиваете",
},
{
title: "Поиск + клик по узлу",
body: "Поисковая строка сверху делает нечёткое совпадение по имени узла, summary и тегам. Кликните по узлу — справа откроется панель с summary, соседями и кнопкой Open Article.",
hint: "Поиск центрирует и подсвечивает; клик подсвечивает соседние рёбра",
},
{
title: "Переключение Layer + Project Tour",
body: "Вкладки layer рядом с All фильтруют граф по одной категории на основе index.md. Project Tour справа проводит вас по заранее заданной последовательности.",
hint: "Используйте Layer, когда узлов слишком много; запустите Tour, если непонятно с чего начать",
},
{
title: "Другие скрытые возможности",
body: "В верхней панели также есть Filter (фильтр по типу / сложности), Export (экспорт графа), Path (поиск пути между двумя узлами) и Theme (смена темы). Нажмите Shift + ?, чтобы увидеть полный список горячих клавиш.",
hint: "Открывайте их по мере необходимости — не нужно запоминать всё сразу",
},
],
},
};
export default ru;
@@ -267,6 +267,40 @@ export const zhTW = {
pathFinder: {
title: "尋找節點間路徑 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · 入門",
skipForever: "不再顯示",
prev: "上一步",
next: "下一步",
finish: "開始探索",
steps: [
{
title: "歡迎進入知識圖",
body: "你看到的圓點和連線是 Understand-Anything 把這份專案抽出來的實體和關係。節點可以是程式碼裡的檔案、類別、函式,也可以是知識 wiki 裡的概念、實體或斷言。",
hint: "5 步以內帶你過完核心操作",
},
{
title: "頂部三個視圖",
body: "Overview 看全貌(力導向圖)· Learn 跟隨預設學習路徑 · Deep Dive 看類型 / 複雜度統計。每個視圖回答一種不同的問法。",
hint: "切視圖前先想清楚自己在問什麼",
},
{
title: "搜尋 + 點節點",
body: "頂部搜尋框模糊匹配節點名 / summary / tags。點任意節點 → 右側詳情面板出現 summary + 鄰居列表 + Open Article 按鈕。",
hint: "搜尋高亮置中,點節點高亮鄰居邊",
},
{
title: "Layer 切換 + Tour",
body: "頂部 All 旁邊的 layer 標籤按 index.md 分類只顯示部分節點。右側 Project Tour 自動按編輯者預設順序導覽。",
hint: "節點太密看不清就用 Layer,沒頭緒就啟 Tour",
},
{
title: "更多隱藏功能",
body: "頂欄還有 Filter(按類型 / 複雜度過濾)、Export(匯出圖)、Path(找兩個節點之間的路徑)、Theme(切換主題)。Shift + ? 看完整快捷鍵。",
hint: "需要時再展開,不要一次記完",
},
],
},
};
export default zhTW;
@@ -267,6 +267,40 @@ export const zh = {
pathFinder: {
title: "查找节点间路径 (P)",
},
onboarding: {
header: "UNDERSTAND-ANYTHING · 入门",
skipForever: "不再显示",
prev: "上一步",
next: "下一步",
finish: "开始探索",
steps: [
{
title: "欢迎进入知识图",
body: "你看到的圆点和连线是 Understand-Anything 把这份项目抽出来的实体和关系。节点可以是代码里的文件、类、函数,也可以是知识 wiki 里的概念、实体或断言。",
hint: "5 步以内带你过完核心操作",
},
{
title: "顶部三个视图",
body: "Overview 看全貌(力导向图)· Learn 跟随预设学习路径 · Deep Dive 看类型 / 复杂度统计。每个视图回答一种不同的问法。",
hint: "切视图前先想清楚自己在问什么",
},
{
title: "搜索 + 点节点",
body: "顶部搜索框模糊匹配节点名 / summary / tags。点任意节点 → 右侧详情面板出现 summary + 邻居列表 + Open Article 按钮。",
hint: "搜索高亮居中,点节点高亮邻居边",
},
{
title: "Layer 切换 + Tour",
body: "顶部 All 旁边的 layer 标签按 index.md 分类只显示部分节点。右侧 Project Tour 自动按编辑者预设顺序导览。",
hint: "节点太密看不清就用 Layer,没头绪就启 Tour",
},
{
title: "更多隐藏功能",
body: "顶栏还有 Filter(按类型 / 复杂度过滤)、Export(导出图)、Path(找两个节点之间的路径)、Theme(切换主题)。Shift + ? 看完整快捷键。",
hint: "需要时再展开,不要一次记完",
},
],
},
};
export default zh;