From c62c9af0910cca07bc3d1e30e5186dcfefe4a795 Mon Sep 17 00:00:00 2001 From: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:58:14 +0000 Subject: [PATCH 1/8] feat(nav): add ProductBar, CopyForLLM, and useShowLanguageSelector New components to support the navigation redesign: - ProductBar: horizontal product switcher shown below the header - CopyForLLM: dropdown menu for markdown copy/view and LLM links - useShowLanguageSelector: extracted hook for language selector visibility - PRODUCT_BAR_HEIGHT constant for derived sticky offsets Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Layout/CopyForLLM.test.tsx | 122 +++++++++++ src/components/Layout/CopyForLLM.tsx | 189 ++++++++++++++++++ src/components/Layout/ProductBar.tsx | 140 +++++++++++++ .../Layout/hooks/useShowLanguageSelector.ts | 18 ++ src/components/Layout/utils/heights.ts | 1 + 5 files changed, 470 insertions(+) create mode 100644 src/components/Layout/CopyForLLM.test.tsx create mode 100644 src/components/Layout/CopyForLLM.tsx create mode 100644 src/components/Layout/ProductBar.tsx create mode 100644 src/components/Layout/hooks/useShowLanguageSelector.ts diff --git a/src/components/Layout/CopyForLLM.test.tsx b/src/components/Layout/CopyForLLM.test.tsx new file mode 100644 index 0000000000..e2fde02363 --- /dev/null +++ b/src/components/Layout/CopyForLLM.test.tsx @@ -0,0 +1,122 @@ +import React, { ReactNode } from 'react'; +import { render, screen, fireEvent, act } from '@testing-library/react'; +import CopyForLLM from './CopyForLLM'; + +const mockUseLayoutContext = jest.fn(() => ({ + activePage: { + language: 'javascript', + languages: ['javascript'], + product: 'pubsub', + page: { + name: 'Test Page', + link: '/docs/test-page', + }, + tree: [], + template: 'mdx' as const, + }, +})); + +jest.mock('src/contexts/layout-context', () => ({ + useLayoutContext: () => mockUseLayoutContext(), +})); + +jest.mock('@reach/router', () => ({ + useLocation: () => ({ pathname: '/docs/test-page' }), +})); + +jest.mock('@ably/ui/core/Icon', () => ({ + __esModule: true, + default: ({ name }: { name: string }) => {name}, +})); + +jest.mock('@ably/ui/core/insights', () => ({ + track: jest.fn(), +})); + +// Mock Radix DropdownMenu to render content directly +jest.mock('@radix-ui/react-dropdown-menu', () => ({ + Root: ({ children }: { children: ReactNode }) =>
{children}
, + Trigger: ({ children }: { children: ReactNode; asChild?: boolean }) =>
{children}
, + Portal: ({ children }: { children: ReactNode }) =>
{children}
, + Content: ({ children }: { children: ReactNode }) =>
{children}
, + Item: ({ children, onSelect, ...props }: { children: ReactNode; onSelect?: (e: Event) => void; asChild?: boolean }) => + props.asChild ? <>{children} :
onSelect?.({ preventDefault: () => {} } as unknown as Event)}>{children}
, + Separator: () =>
, +})); + +describe('CopyForLLM', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllTimers(); + }); + + it('renders the dropdown trigger button', () => { + global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response)); + + render(); + expect(screen.getByText('Copy for LLM')).toBeInTheDocument(); + }); + + it('renders LLM links for ChatGPT, Claude and Perplexity', () => { + global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response)); + + render(); + expect(screen.getByText('Open in ChatGPT')).toBeInTheDocument(); + expect(screen.getByText('Open in Claude')).toBeInTheDocument(); + expect(screen.getByText('Open in Perplexity')).toBeInTheDocument(); + }); + + it('shows markdown items when content is available', async () => { + const mockMarkdown = '# Test content'; + + global.fetch = jest.fn(() => + Promise.resolve({ + ok: true, + headers: { + get: (name: string) => (name === 'Content-Type' ? 'text/markdown' : null), + }, + text: () => Promise.resolve(mockMarkdown), + } as Response), + ); + + const mockWriteText = jest.fn(); + Object.assign(navigator, { clipboard: { writeText: mockWriteText } }); + + jest.useFakeTimers(); + + render(); + + await screen.findByText('View as markdown'); + expect(screen.getByText('Copy as markdown')).toBeInTheDocument(); + + // Click copy + const copyItem = screen.getByText('Copy as markdown').closest('div'); + if (copyItem) { + act(() => { + fireEvent.click(copyItem); + }); + } + + expect(mockWriteText).toHaveBeenCalledWith(mockMarkdown); + + act(() => { + jest.runOnlyPendingTimers(); + }); + jest.useRealTimers(); + }); + + it('does not show markdown items when fetch fails', async () => { + global.fetch = jest.fn(() => Promise.resolve({ ok: false, status: 404 } as Response)); + + render(); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(screen.queryByText('View as markdown')).not.toBeInTheDocument(); + expect(screen.queryByText('Copy as markdown')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Layout/CopyForLLM.tsx b/src/components/Layout/CopyForLLM.tsx new file mode 100644 index 0000000000..f3420ce4c4 --- /dev/null +++ b/src/components/Layout/CopyForLLM.tsx @@ -0,0 +1,189 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useLocation } from '@reach/router'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import Icon from '@ably/ui/core/Icon'; +import { IconName } from '@ably/ui/core/Icon/types'; +import { track } from '@ably/ui/core/insights'; +import { productData } from 'src/data'; +import { languageInfo } from 'src/data/languages'; +import { useLayoutContext } from 'src/contexts/layout-context'; + +const menuItemClassName = + 'flex items-center gap-2 px-3 py-2 text-sm text-neutral-1300 dark:text-neutral-000 hover:bg-neutral-100 dark:hover:bg-neutral-1200 rounded cursor-pointer outline-none'; + +const CopyForLLM: React.FC = () => { + const { activePage } = useLayoutContext(); + const { language, product, page } = activePage; + const location = useLocation(); + const [markdownContent, setMarkdownContent] = useState(null); + const [copyFeedback, setCopyFeedback] = useState(null); + + const llmLinks = useMemo(() => { + const docUrl = `https://ably.com${page.link}.md`; + const prompt = `Fetch the documentation from ${docUrl} and tell me more about ${product ? productData[product]?.nav.name : 'Ably'}'s '${page.name}' feature${language ? ` for ${languageInfo[language]?.label}` : ''}`; + const gptPath = `https://chatgpt.com/?q=${encodeURIComponent(prompt)}`; + const claudePath = `https://claude.ai/new?q=${encodeURIComponent(prompt)}`; + const perplexityPath = `https://www.perplexity.ai/?q=${encodeURIComponent(prompt)}`; + + return [ + { model: 'gpt', label: 'Open in ChatGPT', icon: 'icon-tech-openai', link: gptPath }, + { model: 'claude', label: 'Open in Claude', icon: 'icon-tech-claude-mono', link: claudePath }, + { model: 'perplexity', label: 'Open in Perplexity', icon: 'icon-tech-perplexity', link: perplexityPath }, + ]; + }, [product, page.name, page.link, language]); + + useEffect(() => { + const abortController = new AbortController(); + let isMounted = true; + + const fetchMarkdown = async () => { + try { + const response = await fetch(`${location.pathname}.md`, { + signal: abortController.signal, + headers: { Accept: 'text/markdown' }, + }); + + if (!isMounted) return; + + if (!response.ok) { + if (response.status === 404) { + setMarkdownContent(null); + return; + } + throw new Error(`Failed to fetch markdown: ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get('Content-Type')?.toLowerCase() || ''; + const isMarkdownType = + contentType.includes('text/markdown') || + contentType.includes('application/markdown') || + contentType.includes('text/plain'); + + if (contentType && !isMarkdownType) { + if (contentType.includes('text/html') || contentType.includes('application/json')) { + throw new Error(`Received ${contentType} response instead of markdown for ${location.pathname}.md`); + } + console.warn( + `Markdown fetch: unexpected content type "${contentType}" for ${location.pathname}.md, accepting anyway`, + ); + } + + const content = await response.text(); + if (isMounted) { + setMarkdownContent(content); + } + } catch (error) { + if (!isMounted || (error instanceof Error && error.name === 'AbortError')) return; + const errorMessage = error instanceof Error ? error.message : String(error); + if (!errorMessage.includes('404')) { + console.error(`Failed to fetch markdown for ${location.pathname}:`, { + error: errorMessage, + path: `${location.pathname}.md`, + errorType: error instanceof Error ? error.name : typeof error, + }); + } + setMarkdownContent(null); + } + }; + + fetchMarkdown(); + + return () => { + isMounted = false; + abortController.abort(); + }; + }, [location.pathname]); + + const handleCopyMarkdown = useCallback(() => { + if (!markdownContent) return; + + try { + navigator.clipboard.writeText(markdownContent); + setCopyFeedback('Copied!'); + setTimeout(() => setCopyFeedback(null), 2000); + + track('markdown_copy_link_clicked', { + location: location.pathname, + }); + } catch (error) { + console.error('Failed to copy markdown:', error); + setCopyFeedback('Error!'); + setTimeout(() => setCopyFeedback(null), 2000); + } + }, [markdownContent, location.pathname]); + + return ( + + + + + + + {markdownContent && ( + <> + + { + track('markdown_preview_link_clicked', { + location: location.pathname, + }); + }} + > + + View as markdown + + + { + e.preventDefault(); + handleCopyMarkdown(); + }} + > + + {copyFeedback ?? 'Copy as markdown'} + + + + )} + {llmLinks.map(({ model, label, icon, link }) => ( + + { + track('llm_link_clicked', { + model, + location: location.pathname, + link, + }); + }} + > +
+ + {label} +
+ +
+
+ ))} +
+
+
+ ); +}; + +export default CopyForLLM; diff --git a/src/components/Layout/ProductBar.tsx b/src/components/Layout/ProductBar.tsx new file mode 100644 index 0000000000..1b9c6d66c0 --- /dev/null +++ b/src/components/Layout/ProductBar.tsx @@ -0,0 +1,140 @@ +import { useMemo } from 'react'; +import cn from '@ably/ui/core/utils/cn'; +import Icon from '@ably/ui/core/Icon'; +import { IconName } from '@ably/ui/core/Icon/types'; + +import { productData } from 'src/data'; +import { ProductKey } from 'src/data/types'; +import { useLayoutContext } from 'src/contexts/layout-context'; +import Link from '../Link'; + +type ProductBarItem = { + type: 'product'; + key: ProductKey; + name: string; + link: string; + icon: { closed: IconName; open: IconName }; +}; + +type CustomBarItem = { + type: 'link'; + name: string; + link: string; + icon?: IconName; + external?: boolean; +}; + +type BarDivider = { + type: 'divider'; +}; + +type NavBarItem = ProductBarItem | CustomBarItem | BarDivider; + +const buildNavBarItems = (): NavBarItem[] => { + return (Object.keys(productData) as ProductKey[]) + .filter((key) => key !== 'platform') + .map((key) => ({ + type: 'product', + key, + name: productData[key].nav.name.replace(/^Ably\s+/, ''), + link: productData[key].nav.link ?? `/docs/${key}`, + icon: productData[key].nav.icon, + })); +}; + +// --- Styles --- + +const tabBaseClassName = cn( + 'flex items-center gap-1.5 px-3 py-2.5 whitespace-nowrap rounded-lg transition-colors', + 'ui-text-label3 font-medium', + 'focus-base', +); + +const activeTabClassName = 'text-neutral-1300 dark:text-neutral-000 bg-orange-100 dark:bg-orange-1000'; + +const inactiveTabClassName = cn( + 'text-neutral-800 dark:text-neutral-500', + 'hover:text-neutral-1300 dark:hover:text-neutral-000', + 'hover:bg-neutral-100 dark:hover:bg-neutral-1200', + 'cursor-pointer', +); + +// --- Main component --- + +type ProductBarProps = { + className?: string; +}; + +const ProductBar = ({ className }: ProductBarProps) => { + const { activePage } = useLayoutContext(); + const items = useMemo(buildNavBarItems, []); + + return ( + + ); +}; + +export default ProductBar; diff --git a/src/components/Layout/hooks/useShowLanguageSelector.ts b/src/components/Layout/hooks/useShowLanguageSelector.ts new file mode 100644 index 0000000000..0642958d41 --- /dev/null +++ b/src/components/Layout/hooks/useShowLanguageSelector.ts @@ -0,0 +1,18 @@ +import { useMemo } from 'react'; +import { useLayoutContext } from 'src/contexts/layout-context'; +import { languageData } from 'src/data/languages'; +import { ProductKey } from 'src/data/types'; + +export const useShowLanguageSelector = () => { + const { activePage } = useLayoutContext(); + + return useMemo( + () => + activePage.isDualLanguage || + (activePage.languages.length > 0 && + !activePage.languages.every( + (language) => !Object.keys(languageData[activePage.product as ProductKey] ?? {}).includes(language), + )), + [activePage.languages, activePage.product, activePage.isDualLanguage], + ); +}; diff --git a/src/components/Layout/utils/heights.ts b/src/components/Layout/utils/heights.ts index b58a0f81e0..bbc35e8a91 100644 --- a/src/components/Layout/utils/heights.ts +++ b/src/components/Layout/utils/heights.ts @@ -5,4 +5,5 @@ */ export const LANGUAGE_SELECTOR_HEIGHT = 32; +export const PRODUCT_BAR_HEIGHT = 44; export const INKEEP_ASK_BUTTON_HEIGHT = 48; From 5940a1855f4ccbc939aa84c3b09665b752571a42 Mon Sep 17 00:00:00 2001 From: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:58:20 +0000 Subject: [PATCH 2/8] feat(nav): redesign header with Platform/Products/Examples tabs Replace Documentation/Examples TabMenu with three discrete nav links. Header is now fixed with max-width inner container so content aligns with the body while the background spans full viewport width. Search bar moved to center of header. Added comments to shadow DOM queries for Inkeep widget interaction. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Layout/Header.tsx | 102 +++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 24 deletions(-) diff --git a/src/components/Layout/Header.tsx b/src/components/Layout/Header.tsx index 5eb3bff931..81f638039b 100644 --- a/src/components/Layout/Header.tsx +++ b/src/components/Layout/Header.tsx @@ -4,6 +4,7 @@ import { graphql, useStaticQuery } from 'gatsby'; import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import * as Tooltip from '@radix-ui/react-tooltip'; import { throttle } from 'es-toolkit/compat'; +import cn from '@ably/ui/core/utils/cn'; import Icon from '@ably/ui/core/Icon'; import TabMenu from '@ably/ui/core/TabMenu'; import Logo from '@ably/ui/core/images/logo/ably-logo.svg'; @@ -11,6 +12,7 @@ import { track } from '@ably/ui/core/insights'; import { componentMaxHeight, HEADER_BOTTOM_MARGIN, HEADER_HEIGHT } from '@ably/ui/core/utils/heights'; import { IconName } from '@ably/ui/core/Icon/types'; import LeftSidebar from './LeftSidebar'; +import ProductBar from './ProductBar'; import UserContext from 'src/contexts/user-context'; import ExamplesList from '../Examples/ExamplesList'; import Link from '../Link'; @@ -23,16 +25,12 @@ const MD_BREAKPOINT = 1040; const CLI_ENABLED = false; const MAX_MOBILE_MENU_WIDTH = '560px'; -const desktopTabs = [ - - Documentation - , - - Examples - , -]; +const headerLinkClassName = 'px-3 py-1.5 rounded-lg ui-text-label3 font-medium transition-colors'; +const activeHeaderLinkClassName = 'text-neutral-1300 dark:text-neutral-000 bg-orange-100 dark:bg-orange-1000'; +const inactiveHeaderLinkClassName = + 'text-neutral-800 dark:text-neutral-500 hover:text-neutral-1300 dark:hover:text-neutral-000 hover:bg-neutral-100 dark:hover:bg-neutral-1200'; -const mobileTabs = ['Documentation', 'Examples']; +const mobileTabs = ['Platform', 'Products', 'Examples']; const helpResourcesItems = [ { @@ -151,24 +149,46 @@ const Header: React.FC = () => { }, [sessionState.logOut]); return ( -
-
+
+
+
Ably - + Docs - + {isMobileMenuOpen && (
{ , +
+ +
, +
+ + +
, , ]} rootClassName="h-full overflow-y-hidden min-h-[3.1875rem] flex flex-col" @@ -193,12 +219,39 @@ const Header: React.FC = () => {
)}
+
+ {!externalScriptsData.inkeepSearchEnabled && ( +
+ +
+ )} +
-
+
{externalScriptsData.inkeepChatEnabled && (
+
); }; From 512e58b9c6a0cd8f3010fb479332562b4965016d Mon Sep 17 00:00:00 2001 From: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com> Date: Fri, 27 Mar 2026 15:58:26 +0000 Subject: [PATCH 3/8] feat(nav): restructure layout with ProductBar and full-width borders Move Header and ProductBar outside max-width container so their backgrounds and borders span the full viewport. Add pt-16 offset for fixed header. Integrate CopyForLLM into breadcrumb bar. Remove mt-8 from breadcrumbs, add min-w-0 for overflow safety. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/Layout/Breadcrumbs.tsx | 2 +- src/components/Layout/Layout.tsx | 53 +++++++++++++++++---------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/components/Layout/Breadcrumbs.tsx b/src/components/Layout/Breadcrumbs.tsx index ea0a967763..f4afd3e8b9 100644 --- a/src/components/Layout/Breadcrumbs.tsx +++ b/src/components/Layout/Breadcrumbs.tsx @@ -35,7 +35,7 @@ const Breadcrumbs: React.FC = () => { })(); return ( -