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
70 changes: 62 additions & 8 deletions apps/website/src/components/docs/DocsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { useState, useRef, useEffect } from 'react';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
import { docsConfig, getLibraryConfig, specialDocsPages, type DocsSection, type LibraryId } from '../../lib/docs-config';
import { Pill } from '../ui/Pill';
import { LibraryMark } from './LibraryMark';

interface Props {
Expand Down Expand Up @@ -78,6 +77,52 @@ function LibraryDropdown({ activeLibrary }: { activeLibrary: LibraryId }) {
);
}

/** Small consistent-stroke glyphs keyed by section id (premium-polish pass). */
function SectionGlyph({ id }: { id: string }) {
const common = {
width: 15,
height: 15,
viewBox: '0 0 16 16',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 1.5,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
switch (id) {
case 'getting-started':
return (
<svg {...common}><path d="M3 13c0-3 1-6.5 5-10 4 3.5 5 7 5 10l-2.5-2h-5L3 13Z" /><circle cx="8" cy="7" r="1.4" /></svg>
);
case 'guides':
return (
<svg {...common}><path d="M2.5 3.5A1.5 1.5 0 014 2h4v11H4a1.5 1.5 0 00-1.5 1.5V3.5Z" /><path d="M13.5 3.5A1.5 1.5 0 0012 2H8v11h4a1.5 1.5 0 011.5 1.5V3.5Z" /></svg>
);
case 'concepts':
return (
<svg {...common}><path d="M8 2a4.5 4.5 0 00-2.5 8.2c.6.5 1 1.1 1 1.8h3c0-.7.4-1.3 1-1.8A4.5 4.5 0 008 2Z" /><path d="M6.5 14h3" /></svg>
);
case 'components':
return (
<svg {...common}><rect x="2" y="2" width="5" height="5" rx="1" /><rect x="9" y="2" width="5" height="5" rx="1" /><rect x="2" y="9" width="5" height="5" rx="1" /><rect x="9" y="9" width="5" height="5" rx="1" /></svg>
);
case 'a2ui':
return (
<svg {...common}><rect x="2" y="2.5" width="12" height="11" rx="1.5" /><path d="M4.5 5.5h7" /><path d="M4.5 8h4" /><path d="M4.5 10.5h5.5" /></svg>
);
case 'api':
case 'reference':
return (
<svg {...common}><path d="M5.5 3.5 2 8l3.5 4.5" /><path d="M10.5 3.5 14 8l-3.5 4.5" /></svg>
);
default:
return (
<svg {...common}><circle cx="8" cy="8" r="2" /></svg>
);
}
}

function SectionGroup({
section,
activeLibrary,
Expand All @@ -97,11 +142,14 @@ function SectionGroup({
onClick={() => setOpen(!open)}
className="w-full text-left px-4 py-1.5 flex items-center justify-between docs-sidebar-section-toggle"
>
<span
className="font-mono text-xs uppercase tracking-wider docs-sidebar-section-label"
data-tone={section.color}
>
{section.title}
<span className="docs-sidebar-section-labelrow">
<span className="docs-sidebar-section-glyph"><SectionGlyph id={section.id} /></span>
<span
className="font-mono text-xs uppercase tracking-wider docs-sidebar-section-label"
data-tone={section.color}
>
{section.title}
</span>
</span>
<span className="docs-sidebar-section-caret" data-open={open ? '' : undefined}>
&#9662;
Expand Down Expand Up @@ -142,8 +190,14 @@ export function DocsSidebar({ activeLibrary, activeSection, activeSlug }: Props)
className="w-full text-left px-3 py-2 rounded-lg text-sm flex items-center justify-between docs-sidebar-search-trigger"
onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', metaKey: true }))}
>
<span className="docs-sidebar-search-label">Search docs...</span>
<Pill variant="neutral" className="docs-sidebar-search-kbd">⌘K</Pill>
<span className="docs-sidebar-search-inner">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5L14 14" />
</svg>
<span className="docs-sidebar-search-label">Search docs...</span>
</span>
<kbd className="docs-sidebar-search-kbd">⌘K</kbd>
</button>
</div>

Expand Down
47 changes: 30 additions & 17 deletions apps/website/src/components/docs/DocsTOC.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,44 @@ export function DocsTOC({ headings }: { headings: DocHeading[] }) {
const [activeId, setActiveId] = useState('');

useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setActiveId(entry.target.id);
}
}
},
{ rootMargin: '-80px 0px -80% 0px' },
);
// "Current section" = the last heading above the reading line. Unlike an
// IntersectionObserver band, this always yields an active item after a
// jump-scroll or hash navigation, not only when a heading crosses the band.
const els = headings
.map((h) => document.getElementById(h.id))
.filter((el): el is HTMLElement => el !== null);
if (els.length === 0) return undefined;

for (const heading of headings) {
const el = document.getElementById(heading.id);
if (el) observer.observe(el);
}

return () => observer.disconnect();
let frame = 0;
const update = () => {
frame = 0;
const line = window.scrollY + window.innerHeight * 0.25;
let current = '';
for (const el of els) {
if (el.offsetTop <= line) current = el.id;
else break;
}
setActiveId(current);
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(update);
};
update();
window.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', onScroll, { passive: true });
return () => {
if (frame) cancelAnimationFrame(frame);
window.removeEventListener('scroll', onScroll);
window.removeEventListener('resize', onScroll);
};
}, [headings]);

if (headings.length === 0) return null;

return (
<aside className="hidden xl:block w-56 shrink-0 py-8 pl-8 pr-6 docs-toc">
<p className="font-mono text-xs uppercase tracking-wider mb-3 docs-toc-label">On this page</p>
<nav className="flex flex-col gap-1.5">
<nav className="flex flex-col gap-0.5 docs-toc-nav">
{headings.map((h) => (
<a
key={h.id}
Expand Down
11 changes: 8 additions & 3 deletions apps/website/src/components/docs/PageActions.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,21 @@ beforeEach(() => {
Object.assign(globalThis, { fetch: fetchMock });
});

async function open() {
async function renderActions() {
const { PageActions } = await import('./PageActions');
render(<PageActions library="langgraph" section="guides" slug="streaming" />);
}

async function open() {
await renderActions();
fireEvent.click(screen.getByRole('button', { name: /page actions/i }));
}

describe('PageActions', () => {
it('copies the raw markdown from the route and fires analytics', async () => {
await open();
fireEvent.click(screen.getByRole('menuitem', { name: /copy page as markdown/i }));
// Split-button redesign: copy is the labeled PRIMARY segment, not a menu item.
await renderActions();
fireEvent.click(screen.getByRole('button', { name: /copy page as markdown/i }));
await waitFor(() => expect(writeTextMock).toHaveBeenCalledWith('# Streaming\n\nbody'));
expect(fetchMock).toHaveBeenCalledWith('/api/markdown/langgraph/guides/streaming');
expect(trackMock).toHaveBeenCalledWith(
Expand Down
87 changes: 68 additions & 19 deletions apps/website/src/components/docs/PageActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,38 @@ interface Props {
slug: string;
}

function CopyIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="5" y="5" width="9" height="9" rx="1.5" />
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
</svg>
);
}

function CheckIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 8.5l3.5 3.5L13 5" />
</svg>
);
}

function ChevronIcon() {
return (
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 6.5l4 4 4-4" />
</svg>
);
}

/**
* "Copy page" split button (docs premium-polish pass). The primary segment
* copies the page's raw Markdown — the single most useful docs action in the
* LLM era, previously buried behind an unlabeled "⋯" menu. The chevron opens
* the secondary actions. Menu a11y (first-item focus, arrow roving, Escape,
* focus restore) carried over from the a11y pass (#865).
*/
export function PageActions({ library, section, slug }: Props) {
const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false);
Expand All @@ -21,8 +53,7 @@ export function PageActions({ library, section, slug }: Props) {
const menuRef = useRef<HTMLDivElement>(null);

useEffect(() => {
if (!open) return;
// Menu pattern: focus the first item on open, restore the trigger on close.
if (!open) return undefined;
const items = menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]');
items?.[0]?.focus();
const onDown = (e: MouseEvent) => {
Expand All @@ -40,7 +71,6 @@ export function PageActions({ library, section, slug }: Props) {
};
}, [open]);

// Roving arrow-key navigation over the menu items.
const onMenuKeyDown = (e: React.KeyboardEvent) => {
const items = [...(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
if (items.length === 0) return;
Expand All @@ -57,14 +87,15 @@ export function PageActions({ library, section, slug }: Props) {

const path = `${library}/${section}/${slug}`;
const pageUrl = `${SITE_ORIGIN}/docs/${path}`;
const markdownUrl = `/api/markdown/${path}`;
const chatgptUrl = `https://chatgpt.com/?hints=search&q=${encodeURIComponent(
`Read this Threadplane docs page and help me apply it to my project: ${pageUrl}`,
)}`;
const githubUrl = `${GITHUB_EDIT_BASE}/${path}.mdx`;

const copyMarkdown = async () => {
try {
const res = await fetch(`/api/markdown/${path}`);
const res = await fetch(markdownUrl);
if (!res.ok) throw new Error(String(res.status));
const text = await res.text();
await navigator.clipboard.writeText(text);
Expand All @@ -74,32 +105,40 @@ export function PageActions({ library, section, slug }: Props) {
} catch {
// network/clipboard failure — silently ignore
}
setOpen(false);
};

return (
<div ref={ref} className="docs-page-actions">
<button
type="button"
ref={triggerRef}
aria-label="Page actions"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="docs-page-actions-trigger"
>
<span aria-hidden="true">⋯</span>
</button>
<div className="docs-copy-split">
<button
type="button"
aria-label="Copy page as Markdown"
onClick={copyMarkdown}
className="docs-copy-primary"
data-copied={copied || undefined}
>
{copied ? <CheckIcon /> : <CopyIcon />}
<span>{copied ? 'Copied' : 'Copy page'}</span>
</button>
<button
type="button"
ref={triggerRef}
aria-label="Page actions"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
className="docs-copy-more"
>
<ChevronIcon />
</button>
</div>
{open ? (
<div
role="menu"
ref={menuRef}
onKeyDown={onMenuKeyDown}
className="docs-page-actions-menu"
>
<button type="button" role="menuitem" onClick={copyMarkdown} className="docs-page-actions-item">
{copied ? 'Copied' : 'Copy page as Markdown'}
</button>
<a
role="menuitem"
href={chatgptUrl}
Expand All @@ -110,6 +149,16 @@ export function PageActions({ library, section, slug }: Props) {
>
Open in ChatGPT
</a>
<a
role="menuitem"
href={markdownUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => setOpen(false)}
className="docs-page-actions-item"
>
View as Markdown
</a>
<a
role="menuitem"
href={githubUrl}
Expand Down
Loading
Loading