Skip to content

Commit 0e0dfa7

Browse files
feat(web): landing popups, graph popup click-to-open, assistant position (#1100) (#1102)
* feat(web): restore landing page announcement popups with glassmorphic style (#1100) Retire the global top-right AnnouncementBanner in favour of the original landing-only popup dialogs (AnnouncementPopup + LaunchCelebrationPopup). Both popups are centered with proportional viewport margins (10vh/15vw) and use a glassmorphic surface (bg-black/40 + backdrop-blur-2xl) with light text token overrides for contrast over the dark hero video. * chore(web): remove background plate from landing hero CTA buttons (#1100) * feat(web): skillset graph popup — click-to-open, centered, blurred backdrop (#1100) Replace hover-triggered cursor-following popup with a click-to-open centered dialog. The dialog fills the viewport minus 15% margins on all sides and shows a full-screen backdrop blur. Click the blurred area or the × button to dismiss. Graph nodes now fire onNodeClick instead of onNodeMouseEnter/Leave. * chore(web): default Ornn assistant mascot to right-edge vertically centered (#1100) * chore: add changeset for #1100 --------- Co-authored-by: Shining <250120269+chronoai-shining@users.noreply.github.com>
1 parent fe70690 commit 0e0dfa7

11 files changed

Lines changed: 935 additions & 77 deletions

.changeset/nice-wolves-trade.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"ornn-web": patch
3+
---
4+
5+
Restore landing announcement popups with glassmorphic style; skillset graph popup click-to-open with blurred backdrop; assistant mascot default position right-edge centered

ornn-web/src/App.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import { AdminLayout } from "@/components/layout/AdminLayout";
2929
import { AuthGuard } from "@/components/auth/AuthGuard";
3030
import { AdminGuard } from "@/components/auth/AdminGuard";
3131
import { ErrorBoundary } from "@/components/ErrorBoundary";
32-
import { AnnouncementBanner } from "@/components/announcements/AnnouncementBanner";
3332
import { HighlighterMarkFilter } from "@/pages/landing/HighlighterMark";
3433
import { VersionUpdateBanner } from "@/components/layout/VersionUpdateBanner";
3534
import { PostHogProvider } from "@/components/analytics/PostHogProvider";
@@ -55,8 +54,6 @@ function AnalyticsRoot() {
5554
<PostHogProvider />
5655
<Outlet />
5756
<CookieConsentBanner />
58-
{/* Global announcement surface — top-right headline pill on every page. */}
59-
<AnnouncementBanner />
6057
{!hideAssistant && <AssistantWidget />}
6158
</>
6259
);

ornn-web/src/components/assistant/AssistantWidget.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,12 +91,12 @@ function clampLauncherPos(pos: Point, w = LAUNCHER_W, h = LAUNCHER_H): Point {
9191
};
9292
}
9393

94-
/** Default resting position — bottom-right corner. */
94+
/** Default resting position — right edge, vertically centered. */
9595
function defaultLauncherPos(): Point {
9696
if (typeof window === "undefined") return { x: 0, y: 0 };
9797
return clampLauncherPos({
9898
x: window.innerWidth - LAUNCHER_W - EDGE_MARGIN,
99-
y: window.innerHeight - LAUNCHER_H - EDGE_MARGIN,
99+
y: (window.innerHeight - LAUNCHER_H) / 2,
100100
});
101101
}
102102

ornn-web/src/components/skillset/SkillsetDependencyGraphCanvas.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,7 @@ export interface SkillsetDependencyGraphCanvasProps {
7171
onEdgesChange: (edges: Edge[]) => void;
7272
/** Display-only mode for detail page (no drag/connect/edit). */
7373
readOnly?: boolean | undefined;
74-
/** Hover callback for nodes (used by detail page for package preview dialog).
75-
* Second arg is mouse position for cursor-follow popup. */
74+
/** Click callback for nodes (used by detail page for package preview dialog). */
7675
onHoverMember?: ((ref: string | null, pos?: { clientX: number; clientY: number }) => void) | undefined;
7776
}
7877

@@ -361,15 +360,11 @@ export function SkillsetDependencyGraphCanvas({
361360
nodeTypes={NODE_TYPES}
362361
onNodesChange={onNodesChange}
363362
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
364-
onNodeMouseEnter={(event, node) => {
363+
onNodeClick={(_event, node) => {
365364
if (onHoverMember && node?.id) {
366-
const pos = event && typeof event.clientX === 'number'
367-
? { clientX: event.clientX, clientY: event.clientY }
368-
: undefined;
369-
onHoverMember(node.id, pos);
365+
onHoverMember(node.id);
370366
}
371367
}}
372-
onNodeMouseLeave={() => onHoverMember?.(null)}
373368
fitView
374369
fitViewOptions={FIT_VIEW_OPTIONS}
375370
proOptions={PRO_OPTIONS}

ornn-web/src/pages/LandingPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { Navbar } from "@/components/layout/Navbar";
2222
import { HeroVideo } from "@/pages/landing/HeroVideo";
2323
import { LandingFooter } from "@/pages/landing/LandingFooter";
2424
import { LandingChrome } from "@/pages/landing/LandingChrome";
25+
import { AnnouncementPopup } from "@/pages/landing/AnnouncementPopup";
26+
import { LaunchCelebrationPopup } from "@/pages/landing/LaunchCelebrationPopup";
2527

2628
export function LandingPage() {
2729
return (
@@ -37,6 +39,8 @@ export function LandingPage() {
3739
<HeroVideo />
3840
</main>
3941
<LandingFooter />
42+
<AnnouncementPopup />
43+
<LaunchCelebrationPopup />
4044
</div>
4145
);
4246
}

ornn-web/src/pages/SkillsetDetailPage.tsx

Lines changed: 38 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
* @module pages/SkillsetDetailPage
1212
*/
1313

14-
import { useState, useCallback, useMemo, useRef } from "react";
14+
import { useState, useCallback, useMemo } from "react";
1515
import { useParams, useNavigate, useSearchParams } from "react-router-dom";
1616
import { useTranslation } from "react-i18next";
1717
import { PageTransition } from "@/components/layout/PageTransition";
@@ -64,44 +64,21 @@ export function SkillsetDetailPage() {
6464
const [showPermissions, setShowPermissions] = useState(false);
6565
const [showDelete, setShowDelete] = useState(false);
6666

67-
// Hover state for graph nodes (now canvas-based). Shows floating preview dialog.
68-
// Position tracks cursor for "beside my cursor" placement.
69-
const [hoveredMemberRef, setHoveredMemberRef] = useState<string | null>(null);
70-
const [hoveredPos, setHoveredPos] = useState<{ clientX: number; clientY: number } | null>(null);
67+
// Click-to-open state for graph node package preview dialog.
68+
const [previewMemberRef, setPreviewMemberRef] = useState<string | null>(null);
7169

72-
// Grace timer so the popup survives the gap between the node and the dialog:
73-
// leaving a node SCHEDULES a close, but entering the popup cancels it (#1094 —
74-
// previously the popup was "gone already" before the cursor reached it).
75-
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
76-
const cancelClose = useCallback(() => {
77-
if (closeTimer.current) {
78-
clearTimeout(closeTimer.current);
79-
closeTimer.current = null;
80-
}
81-
}, []);
8270
const closePreview = useCallback(() => {
83-
cancelClose();
84-
setHoveredMemberRef(null);
85-
setHoveredPos(null);
86-
}, [cancelClose]);
71+
setPreviewMemberRef(null);
72+
}, []);
8773

88-
// Stable callback so the memoized graph doesn't re-render on every hover.
89-
const handleHoverMember = useCallback(
90-
(ref: string | null, pos?: { clientX: number; clientY: number }) => {
74+
// Stable callback so the memoized graph doesn't re-render on every click.
75+
const handleClickMember = useCallback(
76+
(ref: string | null, _pos?: { clientX: number; clientY: number }) => {
9177
if (ref) {
92-
cancelClose();
93-
setHoveredMemberRef(ref);
94-
if (pos) setHoveredPos(pos);
95-
} else {
96-
// Left the node — let the cursor reach the dialog (~250ms) before close.
97-
cancelClose();
98-
closeTimer.current = setTimeout(() => {
99-
setHoveredMemberRef(null);
100-
setHoveredPos(null);
101-
}, 250);
78+
setPreviewMemberRef(ref);
10279
}
10380
},
104-
[cancelClose],
81+
[],
10582
);
10683

10784
// Two-id split: delete is GUID-only on the wire; cache cleanup keys on the
@@ -237,40 +214,38 @@ export function SkillsetDetailPage() {
237214
members={graphMembers}
238215
edges={depEdges}
239216
className="h-full"
240-
onHoverMember={handleHoverMember}
217+
onHoverMember={handleClickMember}
241218
/>
242219

243-
{/* Floating package preview dialog for the hovered graph node.
244-
Positioned fixed beside the cursor (offset right+down) so it appears
245-
"right beside my cursor". Larger size for better readability of the
246-
package tree + content. Uses canvas node hover (no more blinking from
247-
SVG/Mermaid). Dismiss on mouseleave of the popup. */}
248-
{hoveredMemberRef && hoveredPos && (
220+
{/* Click-to-open package preview dialog — fixed size, centered. */}
221+
{previewMemberRef && (
249222
<div
250-
className="fixed z-[100] flex w-[800px] max-w-[calc(100vw-2rem)] h-[40vh] flex-col overflow-hidden rounded-md border border-subtle bg-card card-impression text-sm shadow-xl"
251-
style={{
252-
left: Math.min((hoveredPos.clientX ?? 0) + 18, window.innerWidth - 816),
253-
top: Math.min((hoveredPos.clientY ?? 0) + 8, window.innerHeight - 120),
254-
}}
255-
onMouseEnter={cancelClose}
256-
onMouseLeave={closePreview}
223+
className="fixed inset-0 z-[100] flex items-center justify-center"
224+
onClick={closePreview}
257225
>
258-
<div className="flex shrink-0 items-center justify-between border-b border-subtle px-3 py-2 font-mono text-[11px] text-meta">
259-
<span className="truncate font-medium text-strong">{hoveredMemberRef}</span>
260-
<button
261-
type="button"
262-
onClick={closePreview}
263-
className="ml-2 shrink-0 text-meta hover:text-danger"
264-
aria-label="Close preview"
265-
>
266-
×
267-
</button>
268-
</div>
269-
<div className="min-h-0 flex-1 p-2">
270-
<SkillsetMemberViewer
271-
members={skillset.members}
272-
previewRef={hoveredMemberRef}
273-
/>
226+
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
227+
<div
228+
className="relative flex flex-col overflow-hidden rounded-md border border-subtle bg-card card-impression text-sm shadow-xl"
229+
style={{ top: '15vh', bottom: '15vh', left: '15vw', right: '15vw', position: 'fixed' }}
230+
onClick={(e) => e.stopPropagation()}
231+
>
232+
<div className="flex shrink-0 items-center justify-between border-b border-subtle px-3 py-2 font-mono text-[11px] text-meta">
233+
<span className="truncate font-medium text-strong">{previewMemberRef}</span>
234+
<button
235+
type="button"
236+
onClick={closePreview}
237+
className="ml-2 shrink-0 text-meta hover:text-danger"
238+
aria-label="Close preview"
239+
>
240+
×
241+
</button>
242+
</div>
243+
<div className="min-h-0 flex-1 p-2">
244+
<SkillsetMemberViewer
245+
members={skillset.members}
246+
previewRef={previewMemberRef}
247+
/>
248+
</div>
274249
</div>
275250
</div>
276251
)}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* AnnouncementPopup tests.
3+
*
4+
* Covers:
5+
* - Renders the modal when an active announcement exists and the
6+
* user hasn't dismissed that id.
7+
* - Does NOT render when the active id has already been dismissed
8+
* (localStorage flag from a prior visit).
9+
* - Dismiss writes the per-id flag so a re-mount stays closed.
10+
* - Returns null when there is no active announcement.
11+
*
12+
* Mocks `useActiveAnnouncement` directly so the test never pulls in
13+
* the api client / auth store auto-init chain.
14+
*
15+
* @module pages/landing/AnnouncementPopup.test
16+
*/
17+
18+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
19+
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
20+
import type { PublicAnnouncement } from "@/services/announcementsApi";
21+
22+
// jsdom in this project does not ship a working localStorage. Inject
23+
// a minimal in-memory replacement before any test code touches it.
24+
function installFakeLocalStorage() {
25+
const store = new Map<string, string>();
26+
const fake: Storage = {
27+
get length() {
28+
return store.size;
29+
},
30+
clear: () => store.clear(),
31+
getItem: (k) => (store.has(k) ? (store.get(k) as string) : null),
32+
key: (i) => Array.from(store.keys())[i] ?? null,
33+
removeItem: (k) => {
34+
store.delete(k);
35+
},
36+
setItem: (k, v) => {
37+
store.set(k, String(v));
38+
},
39+
};
40+
Object.defineProperty(globalThis, "localStorage", {
41+
value: fake,
42+
configurable: true,
43+
});
44+
}
45+
installFakeLocalStorage();
46+
47+
const mockActive = vi.fn<() => { data: PublicAnnouncement | null }>();
48+
49+
vi.mock("@/hooks/useAnnouncements", () => ({
50+
useActiveAnnouncement: () => mockActive(),
51+
}));
52+
53+
import { AnnouncementPopup } from "./AnnouncementPopup";
54+
55+
describe("AnnouncementPopup", () => {
56+
beforeEach(() => {
57+
localStorage.clear();
58+
mockActive.mockReset();
59+
});
60+
afterEach(() => {
61+
cleanup();
62+
});
63+
64+
it("renders nothing when there is no active announcement", () => {
65+
mockActive.mockReturnValue({ data: null });
66+
render(<AnnouncementPopup />);
67+
expect(screen.queryByRole("heading", { level: 2 })).toBeNull();
68+
});
69+
70+
it("renders the announcement modal when active and not dismissed", async () => {
71+
mockActive.mockReturnValue({
72+
data: {
73+
id: "a-1",
74+
titleEn: "Ornn 1.2 is live",
75+
titleZh: "",
76+
bodyMarkdownEn: "**Now with** chained skills.",
77+
bodyMarkdownZh: "",
78+
ctaLabelEn: "See changelog",
79+
ctaLabelZh: "",
80+
ctaUrl: "https://ornn.dev/changelog",
81+
},
82+
});
83+
render(<AnnouncementPopup />);
84+
expect(
85+
await screen.findByRole("heading", { name: /Ornn 1\.2 is live/i }),
86+
).toBeInTheDocument();
87+
const cta = screen.getByRole("link", { name: /See changelog/i });
88+
expect(cta).toHaveAttribute("href", "https://ornn.dev/changelog");
89+
expect(cta).toHaveAttribute("target", "_blank");
90+
});
91+
92+
it("does not render when the active id has been dismissed", () => {
93+
localStorage.setItem("ornn:announcement:dismissed:a-1", "1");
94+
mockActive.mockReturnValue({
95+
data: {
96+
id: "a-1",
97+
titleEn: "Already-seen news",
98+
titleZh: "",
99+
bodyMarkdownEn: "Body",
100+
bodyMarkdownZh: "",
101+
ctaLabelEn: null,
102+
ctaLabelZh: null,
103+
ctaUrl: null,
104+
},
105+
});
106+
render(<AnnouncementPopup />);
107+
expect(screen.queryByRole("heading", { name: /Already-seen news/i })).toBeNull();
108+
});
109+
110+
it("dismiss button closes and persists the flag", async () => {
111+
mockActive.mockReturnValue({
112+
data: {
113+
id: "a-2",
114+
titleEn: "Hello there",
115+
titleZh: "",
116+
bodyMarkdownEn: "Body",
117+
bodyMarkdownZh: "",
118+
ctaLabelEn: null,
119+
ctaLabelZh: null,
120+
ctaUrl: null,
121+
},
122+
});
123+
render(<AnnouncementPopup />);
124+
expect(
125+
await screen.findByRole("heading", { name: /Hello there/i }),
126+
).toBeInTheDocument();
127+
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
128+
expect(localStorage.getItem("ornn:announcement:dismissed:a-2")).toBe("1");
129+
});
130+
});

0 commit comments

Comments
 (0)