Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions understand-anything-plugin/packages/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
34 changes: 34 additions & 0 deletions understand-anything-plugin/packages/dashboard/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
34 changes: 34 additions & 0 deletions understand-anything-plugin/packages/dashboard/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading
Loading