Skip to content
10 changes: 10 additions & 0 deletions apps/website/e2e/docs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,16 @@ test.describe('Docs slug page', () => {
await expect(page.locator('article').first()).toBeVisible();
});

test('renders the branded chrome (sidebar mark, page-header eyebrow, prev/next direction)', async ({ page }) => {
await page.goto(route);
// Sidebar shows the active library's logo mark
await expect(page.locator('aside img[src="/logos/langgraph.svg"]').first()).toBeVisible();
// Branded page header eyebrow
await expect(page.getByText(/LangGraph\s+·\s+Getting Started/i).first()).toBeVisible();
// Prev/Next: introduction is the first page, so a "Next →" card is present
await expect(page.getByText('Next →').first()).toBeVisible();
});

test('breadcrumb shows the library + page title', async ({ page }) => {
await page.goto(route);
const breadcrumb = page.locator('nav[aria-label="Breadcrumb"]').first();
Expand Down
2 changes: 2 additions & 0 deletions apps/website/src/app/docs/[library]/[section]/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { DocsSidebar } from '../../../../../components/docs/DocsSidebar';
import { MdxRenderer } from '../../../../../components/docs/MdxRenderer';
import { DocsSearch } from '../../../../../components/docs/DocsSearch';
import { DocsBreadcrumb } from '../../../../../components/docs/DocsBreadcrumb';
import { DocsPageHeader } from '../../../../../components/docs/DocsPageHeader';
import { DocsPrevNext } from '../../../../../components/docs/DocsPrevNext';
import { getDocBySlug, getAllDocSlugs, getDocMetadata } from '../../../../../lib/docs';
import { ApiDocRenderer, type ApiDocEntry } from '../../../../../components/docs/ApiDocRenderer';
Expand Down Expand Up @@ -73,6 +74,7 @@ export default async function DocsPage({ params }: DocsRouteProps) {
<div className="flex-1 min-w-0">
<div className="px-6 md:px-12 pt-6">
<DocsBreadcrumb library={library as LibraryId} section={section} slug={slug} title={doc.title} />
<DocsPageHeader library={library as LibraryId} section={section} />
</div>
<article className="flex-1 py-8 px-4 sm:px-6 md:px-12 md:max-w-3xl overflow-x-hidden">
<MdxRenderer
Expand Down
50 changes: 50 additions & 0 deletions apps/website/src/components/docs/DocsPageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react';
import { tokens } from '@threadplane/design-tokens';
import { LibraryMark } from './LibraryMark';
import { getLibraryConfig, getDocsSection, type LibraryId } from '../../lib/docs-config';

function humanize(s: string): string {
return s.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}

interface Props {
library: LibraryId;
section: string;
/** Right-aligned slot for per-page actions (Spec 2). Optional. */
actions?: ReactNode;
}

export function DocsPageHeader({ library, section, actions }: Props) {
const libTitle = getLibraryConfig(library)?.title ?? library;
const sectionTitle = getDocsSection(library, section)?.title ?? humanize(section);

return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
marginTop: 12,
marginBottom: 20,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, minWidth: 0 }}>
<LibraryMark library={library} size={34} />
<span
style={{
fontFamily: tokens.typography.fontMono,
fontSize: 11,
letterSpacing: '0.08em',
textTransform: 'uppercase',
fontWeight: 700,
color: tokens.colors.accent,
}}
>
{libTitle} · {sectionTitle}
</span>
</div>
{actions ? <div style={{ flex: '0 0 auto' }}>{actions}</div> : null}
</div>
);
}
13 changes: 9 additions & 4 deletions apps/website/src/components/docs/DocsPrevNext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,21 @@ export function DocsPrevNext({ library, section, slug }: Props) {
marginBottom: 16,
}}
>
<style>{`
[data-ui="docs-card"] { transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; }
[data-ui="docs-card"]:hover { border-color: ${tokens.colors.accentBorderHover}; box-shadow: ${tokens.shadows.md}; transform: translateY(-1px); }
@media (prefers-reduced-motion: reduce) { [data-ui="docs-card"]:hover { transform: none; } }
`}</style>
{prev ? (
<Link href={prev.href} style={{ textDecoration: 'none' }}>
<Card padding="md" hoverable style={{ height: '100%' }}>
<Card padding="md" data-ui="docs-card" style={{ height: '100%' }}>
<Eyebrow style={{ marginBottom: 8 }}>← Previous</Eyebrow>
<div
style={{
fontFamily: tokens.typography.fontSans,
fontSize: 16,
fontWeight: 600,
color: tokens.colors.textPrimary,
color: tokens.colors.accent,
}}
>
{prev.title}
Expand All @@ -76,14 +81,14 @@ export function DocsPrevNext({ library, section, slug }: Props) {
)}
{next ? (
<Link href={next.href} style={{ textDecoration: 'none' }}>
<Card padding="md" hoverable style={{ height: '100%', textAlign: 'right' }}>
<Card padding="md" data-ui="docs-card" style={{ height: '100%', textAlign: 'right' }}>
<Eyebrow style={{ marginBottom: 8 }}>Next →</Eyebrow>
<div
style={{
fontFamily: tokens.typography.fontSans,
fontSize: 16,
fontWeight: 600,
color: tokens.colors.textPrimary,
color: tokens.colors.accent,
}}
>
{next.title}
Expand Down
59 changes: 40 additions & 19 deletions apps/website/src/components/docs/DocsSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation';
import { docsConfig, getLibraryConfig, type DocsSection, type LibraryId } from '../../lib/docs-config';
import { tokens } from '@threadplane/design-tokens';
import { Pill } from '../ui/Pill';
import { LibraryMark } from './LibraryMark';

interface Props {
activeLibrary: LibraryId;
Expand Down Expand Up @@ -40,8 +41,11 @@ function LibraryDropdown({ activeLibrary }: { activeLibrary: LibraryId }) {
fontWeight: 600,
}}
>
<span style={{ fontFamily: tokens.typography.fontMono, fontSize: '0.8rem' }}>
{currentLib?.title ?? activeLibrary}
<span style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
<LibraryMark library={activeLibrary} size={20} />
<span style={{ fontFamily: tokens.typography.fontMono, fontSize: '0.8rem' }}>
{currentLib?.title ?? activeLibrary}
</span>
</span>
<span
style={{
Expand Down Expand Up @@ -71,25 +75,30 @@ function LibraryDropdown({ activeLibrary }: { activeLibrary: LibraryId }) {
setOpen(false);
router.push(`/docs/${lib.id}/getting-started/introduction`);
}}
className="w-full text-left px-3 py-2.5 text-sm flex flex-col"
className="w-full text-left px-3 py-2.5 text-sm flex items-start gap-2.5"
style={{
background: lib.id === activeLibrary ? tokens.colors.accentSurface : 'transparent',
border: 'none',
cursor: 'pointer',
}}
>
<span
style={{
fontFamily: tokens.typography.fontMono,
fontWeight: 600,
color: lib.id === activeLibrary ? tokens.colors.accent : tokens.colors.textPrimary,
fontSize: '0.8rem',
}}
>
{lib.title}
<span style={{ marginTop: 1 }}>
<LibraryMark library={lib.id} size={20} />
</span>
<span style={{ fontSize: '0.7rem', color: tokens.colors.textMuted, marginTop: 2 }}>
{lib.description}
<span className="flex flex-col" style={{ minWidth: 0 }}>
<span
style={{
fontFamily: tokens.typography.fontMono,
fontWeight: 600,
color: lib.id === activeLibrary ? tokens.colors.accent : tokens.colors.textPrimary,
fontSize: '0.8rem',
}}
>
{lib.title}
</span>
<span style={{ fontSize: '0.7rem', color: tokens.colors.textMuted, marginTop: 2 }}>
{lib.description}
</span>
</span>
</button>
))}
Expand Down Expand Up @@ -146,12 +155,10 @@ function SectionGroup({
<Link
key={`${page.section}/${page.slug}`}
href={`/docs/${activeLibrary}/${page.section}/${page.slug}`}
data-docs-navlink
data-active={isActive || undefined}
className="px-4 py-1.5 text-sm mx-2 rounded-md transition-all"
style={{
color: isActive ? tokens.colors.accent : tokens.colors.textSecondary,
background: isActive ? tokens.colors.accentSurface : 'transparent',
fontSize: '0.825rem',
}}
style={{ fontSize: '0.825rem' }}
>
{page.title}
</Link>
Expand All @@ -177,6 +184,20 @@ export function DocsSidebar({ activeLibrary, activeSection, activeSlug }: Props)
top: '5rem',
}}
>
<style>{`
[data-docs-navlink] {
color: ${tokens.colors.textSecondary};
background: transparent;
}
[data-docs-navlink][data-active] {
color: ${tokens.colors.accent};
background: ${tokens.colors.accentSurface};
}
[data-docs-navlink]:not([data-active]):hover {
background: ${tokens.surfaces.surfaceDim};
color: ${tokens.colors.textPrimary};
}
`}</style>
{/* Search trigger */}
<div className="px-4 mb-4">
<button
Expand Down
29 changes: 29 additions & 0 deletions apps/website/src/components/docs/LibraryMark.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: MIT
// @vitest-environment jsdom
import React from 'react';
import { describe, expect, it } from 'vitest';
import { render } from '@testing-library/react';
import { LibraryMark } from './LibraryMark';

describe('LibraryMark', () => {
it('renders a logo image for a logo-backed library', () => {
const { container } = render(<LibraryMark library="langgraph" />);
const img = container.querySelector('img');
expect(img).not.toBeNull();
expect(img?.getAttribute('src')).toBe('/logos/langgraph.svg');
expect(img?.getAttribute('alt')).toBe('');
});

it('renders an inline glyph (svg, no img) for an in-house library', () => {
const { container } = render(<LibraryMark library="chat" />);
expect(container.querySelector('img')).toBeNull();
expect(container.querySelector('svg')).not.toBeNull();
});

it('maps ag-ui and a2ui to their vendor logos', () => {
const { container: a } = render(<LibraryMark library="ag-ui" />);
expect(a.querySelector('img')?.getAttribute('src')).toBe('/logos/runtimes/copilotkit.svg');
const { container: b } = render(<LibraryMark library="a2ui" />);
expect(b.querySelector('img')?.getAttribute('src')).toBe('/logos/providers/google.svg');
});
});
104 changes: 104 additions & 0 deletions apps/website/src/components/docs/LibraryMark.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { tokens } from '@threadplane/design-tokens';
import type { LibraryId } from '../../lib/docs-config';

type GlyphKey = 'chat' | 'key' | 'pulse';

type MarkEntry =
| { kind: 'logo'; src: string }
| { kind: 'glyph'; glyph: GlyphKey };

const MARKS: Record<LibraryId, MarkEntry> = {
langgraph: { kind: 'logo', src: '/logos/langgraph.svg' },
'ag-ui': { kind: 'logo', src: '/logos/runtimes/copilotkit.svg' },
a2ui: { kind: 'logo', src: '/logos/providers/google.svg' },
render: { kind: 'logo', src: '/logos/surface/vercel.svg' },
chat: { kind: 'glyph', glyph: 'chat' },
licensing: { kind: 'glyph', glyph: 'key' },
telemetry: { kind: 'glyph', glyph: 'pulse' },
};

function ChatGlyph({ s }: { s: number }) {
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4 5h16v11H8l-4 4V5Z" />
</svg>
);
}

function KeyGlyph({ s }: { s: number }) {
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="8" cy="12" r="3" />
<path d="M11 12h9M17 12v4" />
</svg>
);
}

function PulseGlyph({ s }: { s: number }) {
return (
<svg width={s} height={s} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M3 13h4l3-8 4 16 3-8h4" />
</svg>
);
}

const GLYPHS: Record<GlyphKey, (props: { s: number }) => React.JSX.Element> = {
chat: ChatGlyph,
key: KeyGlyph,
pulse: PulseGlyph,
};

interface Props {
library: LibraryId;
/** Outer chip size in px. Default 24. */
size?: number;
}

export function LibraryMark({ library, size = 24 }: Props) {
const mark = MARKS[library];
const base = {
width: size,
height: size,
borderRadius: tokens.radius.md,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flex: '0 0 auto',
} as const;

if (mark.kind === 'logo') {
const inner = Math.round(size * 0.6);
return (
<span
style={{
...base,
background: tokens.surfaces.surface,
border: `1px solid ${tokens.surfaces.border}`,
}}
>
<img
src={mark.src}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
style={{ width: inner, height: inner, objectFit: 'contain' }}
/>
</span>
);
}

const Glyph = GLYPHS[mark.glyph];
return (
<span
style={{
...base,
background: tokens.colors.accentSurface,
border: `1px solid ${tokens.colors.accentBorder}`,
color: tokens.colors.accent,
}}
>
<Glyph s={Math.round(size * 0.55)} />
</span>
);
}
Loading
Loading