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
5 changes: 5 additions & 0 deletions .changeset/nice-wolves-trade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-web": patch
---

Restore landing announcement popups with glassmorphic style; skillset graph popup click-to-open with blurred backdrop; assistant mascot default position right-edge centered
3 changes: 0 additions & 3 deletions ornn-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { AdminLayout } from "@/components/layout/AdminLayout";
import { AuthGuard } from "@/components/auth/AuthGuard";
import { AdminGuard } from "@/components/auth/AdminGuard";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { AnnouncementBanner } from "@/components/announcements/AnnouncementBanner";
import { HighlighterMarkFilter } from "@/pages/landing/HighlighterMark";
import { VersionUpdateBanner } from "@/components/layout/VersionUpdateBanner";
import { PostHogProvider } from "@/components/analytics/PostHogProvider";
Expand All @@ -55,8 +54,6 @@ function AnalyticsRoot() {
<PostHogProvider />
<Outlet />
<CookieConsentBanner />
{/* Global announcement surface — top-right headline pill on every page. */}
<AnnouncementBanner />
{!hideAssistant && <AssistantWidget />}
</>
);
Expand Down
4 changes: 2 additions & 2 deletions ornn-web/src/components/assistant/AssistantWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,12 @@ function clampLauncherPos(pos: Point, w = LAUNCHER_W, h = LAUNCHER_H): Point {
};
}

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,7 @@ export interface SkillsetDependencyGraphCanvasProps {
onEdgesChange: (edges: Edge[]) => void;
/** Display-only mode for detail page (no drag/connect/edit). */
readOnly?: boolean | undefined;
/** Hover callback for nodes (used by detail page for package preview dialog).
* Second arg is mouse position for cursor-follow popup. */
/** Click callback for nodes (used by detail page for package preview dialog). */
onHoverMember?: ((ref: string | null, pos?: { clientX: number; clientY: number }) => void) | undefined;
}

Expand Down Expand Up @@ -361,15 +360,11 @@ export function SkillsetDependencyGraphCanvas({
nodeTypes={NODE_TYPES}
onNodesChange={onNodesChange}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
onNodeMouseEnter={(event, node) => {
onNodeClick={(_event, node) => {
if (onHoverMember && node?.id) {
const pos = event && typeof event.clientX === 'number'
? { clientX: event.clientX, clientY: event.clientY }
: undefined;
onHoverMember(node.id, pos);
onHoverMember(node.id);
}
}}
onNodeMouseLeave={() => onHoverMember?.(null)}
fitView
fitViewOptions={FIT_VIEW_OPTIONS}
proOptions={PRO_OPTIONS}
Expand Down
4 changes: 4 additions & 0 deletions ornn-web/src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { Navbar } from "@/components/layout/Navbar";
import { HeroVideo } from "@/pages/landing/HeroVideo";
import { LandingFooter } from "@/pages/landing/LandingFooter";
import { LandingChrome } from "@/pages/landing/LandingChrome";
import { AnnouncementPopup } from "@/pages/landing/AnnouncementPopup";
import { LaunchCelebrationPopup } from "@/pages/landing/LaunchCelebrationPopup";

export function LandingPage() {
return (
Expand All @@ -37,6 +39,8 @@ export function LandingPage() {
<HeroVideo />
</main>
<LandingFooter />
<AnnouncementPopup />
<LaunchCelebrationPopup />
</div>
);
}
101 changes: 38 additions & 63 deletions ornn-web/src/pages/SkillsetDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
* @module pages/SkillsetDetailPage
*/

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

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

// Grace timer so the popup survives the gap between the node and the dialog:
// leaving a node SCHEDULES a close, but entering the popup cancels it (#1094 —
// previously the popup was "gone already" before the cursor reached it).
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancelClose = useCallback(() => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}, []);
const closePreview = useCallback(() => {
cancelClose();
setHoveredMemberRef(null);
setHoveredPos(null);
}, [cancelClose]);
setPreviewMemberRef(null);
}, []);

// Stable callback so the memoized graph doesn't re-render on every hover.
const handleHoverMember = useCallback(
(ref: string | null, pos?: { clientX: number; clientY: number }) => {
// Stable callback so the memoized graph doesn't re-render on every click.
const handleClickMember = useCallback(
(ref: string | null, _pos?: { clientX: number; clientY: number }) => {
if (ref) {
cancelClose();
setHoveredMemberRef(ref);
if (pos) setHoveredPos(pos);
} else {
// Left the node — let the cursor reach the dialog (~250ms) before close.
cancelClose();
closeTimer.current = setTimeout(() => {
setHoveredMemberRef(null);
setHoveredPos(null);
}, 250);
setPreviewMemberRef(ref);
}
},
[cancelClose],
[],
);

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

{/* Floating package preview dialog for the hovered graph node.
Positioned fixed beside the cursor (offset right+down) so it appears
"right beside my cursor". Larger size for better readability of the
package tree + content. Uses canvas node hover (no more blinking from
SVG/Mermaid). Dismiss on mouseleave of the popup. */}
{hoveredMemberRef && hoveredPos && (
{/* Click-to-open package preview dialog — fixed size, centered. */}
{previewMemberRef && (
<div
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"
style={{
left: Math.min((hoveredPos.clientX ?? 0) + 18, window.innerWidth - 816),
top: Math.min((hoveredPos.clientY ?? 0) + 8, window.innerHeight - 120),
}}
onMouseEnter={cancelClose}
onMouseLeave={closePreview}
className="fixed inset-0 z-[100] flex items-center justify-center"
onClick={closePreview}
>
<div className="flex shrink-0 items-center justify-between border-b border-subtle px-3 py-2 font-mono text-[11px] text-meta">
<span className="truncate font-medium text-strong">{hoveredMemberRef}</span>
<button
type="button"
onClick={closePreview}
className="ml-2 shrink-0 text-meta hover:text-danger"
aria-label="Close preview"
>
×
</button>
</div>
<div className="min-h-0 flex-1 p-2">
<SkillsetMemberViewer
members={skillset.members}
previewRef={hoveredMemberRef}
/>
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" />
<div
className="relative flex flex-col overflow-hidden rounded-md border border-subtle bg-card card-impression text-sm shadow-xl"
style={{ top: '15vh', bottom: '15vh', left: '15vw', right: '15vw', position: 'fixed' }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex shrink-0 items-center justify-between border-b border-subtle px-3 py-2 font-mono text-[11px] text-meta">
<span className="truncate font-medium text-strong">{previewMemberRef}</span>
<button
type="button"
onClick={closePreview}
className="ml-2 shrink-0 text-meta hover:text-danger"
aria-label="Close preview"
>
×
</button>
</div>
<div className="min-h-0 flex-1 p-2">
<SkillsetMemberViewer
members={skillset.members}
previewRef={previewMemberRef}
/>
</div>
</div>
</div>
)}
Expand Down
130 changes: 130 additions & 0 deletions ornn-web/src/pages/landing/AnnouncementPopup.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/**
* AnnouncementPopup tests.
*
* Covers:
* - Renders the modal when an active announcement exists and the
* user hasn't dismissed that id.
* - Does NOT render when the active id has already been dismissed
* (localStorage flag from a prior visit).
* - Dismiss writes the per-id flag so a re-mount stays closed.
* - Returns null when there is no active announcement.
*
* Mocks `useActiveAnnouncement` directly so the test never pulls in
* the api client / auth store auto-init chain.
*
* @module pages/landing/AnnouncementPopup.test
*/

import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import type { PublicAnnouncement } from "@/services/announcementsApi";

// jsdom in this project does not ship a working localStorage. Inject
// a minimal in-memory replacement before any test code touches it.
function installFakeLocalStorage() {
const store = new Map<string, string>();
const fake: Storage = {
get length() {
return store.size;
},
clear: () => store.clear(),
getItem: (k) => (store.has(k) ? (store.get(k) as string) : null),
key: (i) => Array.from(store.keys())[i] ?? null,
removeItem: (k) => {
store.delete(k);
},
setItem: (k, v) => {
store.set(k, String(v));
},
};
Object.defineProperty(globalThis, "localStorage", {
value: fake,
configurable: true,
});
}
installFakeLocalStorage();

const mockActive = vi.fn<() => { data: PublicAnnouncement | null }>();

vi.mock("@/hooks/useAnnouncements", () => ({
useActiveAnnouncement: () => mockActive(),
}));

import { AnnouncementPopup } from "./AnnouncementPopup";

describe("AnnouncementPopup", () => {
beforeEach(() => {
localStorage.clear();
mockActive.mockReset();
});
afterEach(() => {
cleanup();
});

it("renders nothing when there is no active announcement", () => {
mockActive.mockReturnValue({ data: null });
render(<AnnouncementPopup />);
expect(screen.queryByRole("heading", { level: 2 })).toBeNull();
});

it("renders the announcement modal when active and not dismissed", async () => {
mockActive.mockReturnValue({
data: {
id: "a-1",
titleEn: "Ornn 1.2 is live",
titleZh: "",
bodyMarkdownEn: "**Now with** chained skills.",
bodyMarkdownZh: "",
ctaLabelEn: "See changelog",
ctaLabelZh: "",
ctaUrl: "https://ornn.dev/changelog",
},
});
render(<AnnouncementPopup />);
expect(
await screen.findByRole("heading", { name: /Ornn 1\.2 is live/i }),
).toBeInTheDocument();
const cta = screen.getByRole("link", { name: /See changelog/i });
expect(cta).toHaveAttribute("href", "https://ornn.dev/changelog");
expect(cta).toHaveAttribute("target", "_blank");
});

it("does not render when the active id has been dismissed", () => {
localStorage.setItem("ornn:announcement:dismissed:a-1", "1");
mockActive.mockReturnValue({
data: {
id: "a-1",
titleEn: "Already-seen news",
titleZh: "",
bodyMarkdownEn: "Body",
bodyMarkdownZh: "",
ctaLabelEn: null,
ctaLabelZh: null,
ctaUrl: null,
},
});
render(<AnnouncementPopup />);
expect(screen.queryByRole("heading", { name: /Already-seen news/i })).toBeNull();
});

it("dismiss button closes and persists the flag", async () => {
mockActive.mockReturnValue({
data: {
id: "a-2",
titleEn: "Hello there",
titleZh: "",
bodyMarkdownEn: "Body",
bodyMarkdownZh: "",
ctaLabelEn: null,
ctaLabelZh: null,
ctaUrl: null,
},
});
render(<AnnouncementPopup />);
expect(
await screen.findByRole("heading", { name: /Hello there/i }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(localStorage.getItem("ornn:announcement:dismissed:a-2")).toBe("1");
});
});
Loading