fix(docs): fix Core Web Vitals regressions on docs.sim.ai (#5630)

* fix(docs): fix Core Web Vitals regressions on docs.sim.ai

Empirically measured under real trace-based (devtools) CPU/network
throttling against the live site: mobile Performance 59, LCP 9.2s
(TTFB 745ms + 8.4s element render delay).

- sidebar-components.tsx / [lang]/layout.tsx: the docs sidebar renders
  every page in the doc tree as a link at once. Next's default
  viewport-prefetch fired an RSC payload fetch for every one of them on
  initial load - dozens of concurrent requests competing with the page's
  own content for bandwidth. Wired fumadocs' documented `sidebar.prefetch`
  option through to the custom SidebarItem/SidebarFolder components (which
  were bypassing it entirely, using next/link directly with no prefetch
  prop) via the `useSidebar()` context hook.
- video.tsx: `autoPlay` forces browsers to fetch the full video file
  immediately on mount regardless of `preload`. Gated actual src loading
  behind an IntersectionObserver so a page with several of these doesn't
  pull down every video up front (5MB across 3 requests, in this case).
  Single shared component - fixes every doc page that embeds one.
- proxy.ts: the i18n middleware matcher excluded favicon/robots.txt/etc
  but not `icon.svg`, so every request for it got routed through i18n
  negotiation instead of served as a static file, 404ing in production.
- next.config.ts: enable productionBrowserSourceMaps - safe since this
  repo's source is already fully public, real debuggability benefit,
  zero performance cost.
- shiki 4.0.0 -> 4.3.1 (verified: syntax highlighting still renders
  correctly). Attempted a coordinated fumadocs-core/ui/mdx/openapi
  upgrade to latest; fumadocs-openapi's v11 factory function became
  client-only (breaking change beyond its declared peer deps, requiring
  a component-boundary restructure), so only the safe, verified,
  docs-exclusive bumps (fumadocs-core/ui/mdx, shiki) are included here -
  the openapi major bump needs its own dedicated migration PR.

Verified via a real production build (dummy env, all 3974 pages
including API reference render/build cleanly) and a clean (non-stale)
local server: Performance 59 -> 71 measured under real devtools
throttling, RSC prefetch requests 63 -> 11, video requests/bytes 3/5MB
-> 0. A pre-existing React hydration warning (#418) was found and
confirmed present on live production before any of these changes,
unrelated to this diff - documented, not blocking.

* fix(docs): fall back to eager video loading without IntersectionObserver

The lazy-load gate from the previous commit threw before isInView could
ever become true in environments lacking IntersectionObserver (older
browsers, some embedded webviews), leaving videos permanently
source-less instead of falling back to eager loading.

* chore(docs): drop non-TSDoc inline comments

Repo convention is TSDoc-only, no plain // comments.

* fix(docs): accessibility and SEO defects across the docs app

Audited with parallel subagents against the accessibility and SEO skill
checklists, each fix verified by reading the actual code (not assumed):

Accessibility:
- lightbox.tsx: focus was never captured/restored on close, and Tab
  escaped the modal to the page behind it (no focus trap on the single
  focusable element)
- heading.tsx: the per-heading copy-link icon only appeared on hover,
  invisible to keyboard-only navigation (added peer-focus-visible)
- navbar.tsx: active nav tab had no aria-current
- response-section.tsx: the status-code dropdown had no
  aria-haspopup/aria-expanded/role, and no Escape-to-close
- workflow-preview.tsx: same focus-trap gap as lightbox.tsx on the
  expanded-canvas modal

SEO:
- page.tsx: generateMetadata's hreflang/canonical URLs used a naive
  String.replace to strip the locale prefix, which also matched "/en"
  inside unrelated slugs (platform/enterprise, integrations/enrich,
  platform/self-hosting/environment-variables), corrupting those pages'
  canonical and alternate-language URLs. Replaced with a prefix-only strip.
- structured-data.tsx: the SoftwareApplication JSON-LD block compared
  url === baseUrl (no trailing slash) against the homepage's actual url
  (always has a trailing slash), so the condition was always false and
  this structured data never rendered anywhere, including the homepage.
- structured-data.tsx: "Mothership" in the indexed featureList violated
  the constitution's required language (the agent is "Sim", the surface
  is "Chat") - this ships in JSON-LD search engines parse.

* fix(docs): defer the Ask Sim chat widget's heavy deps until opened

The chat panel (useChat from @ai-sdk/react, Streamdown + its CSS) was
mounted unconditionally in the root layout on every single page, so
its full weight loaded and executed even though the widget starts
closed on every page view.

Traced via the LCP breakdown insight under real devtools CPU/network
throttling: the LCP text element (the intro paragraph) had a ~8s
element render delay despite a ~13ms TTFB, and bootup-time attributed
~4.3s of scripting time to a single chunk containing React/ReactDOM's
own runtime plus this widget's eagerly-bundled dependencies.

Split into a lightweight ask-ai.tsx (just the toggle button + open
state) and ask-ai-panel.tsx (the actual chat UI, useChat, Streamdown),
loaded via next/dynamic(..., { ssr: false }) only when the user opens
the widget. Verified: the panel's chunk now has zero network requests
on initial page load.

Measured (mobile, devtools throttling, /introduction):
- Performance: 69 -> 75
- LCP: 8.0s -> 6.4s
- TBT: 260ms -> 130ms

The remaining ~6.4s LCP delay traces to the same shared chunk, now
identified as core React/ReactDOM hydration cost for this page's
sidebar/TOC/breadcrumb tree rather than an isolated bug - a real,
larger initiative (hydration architecture, not a surgical fix),
documented here rather than rushed.

* fix(docs): preserve Ask Sim chat state across close/reopen

The panel split unmounted AskAIPanel entirely on close, discarding
useChat's message state - reopening always started an empty
conversation, unlike the original single-component layout where
useChat lived in a component that never unmounted.

Fixed by keeping the panel mounted (via a hasOpened flag that never
resets) once first opened, and having the panel itself return null
when closed rather than being conditionally removed from the tree by
its parent - hooks still run every render, so useChat's state persists
across visibility toggles. The dynamic import still only fires on the
first open, so the initial-load win is unchanged.

Verified via a real click-through (open, type, close, reopen): input
persists correctly, and the panel chunk still has zero network
requests on initial page load. Performance unchanged at 75.

* chore(docs): lint fixes (import order, formatting)

* fix(docs): fill the Ask Sim UI gap while the panel chunk loads

handleOpen set open=true synchronously, hiding the trigger button
before the dynamically imported panel had a chance to render anything
(next/dynamic renders null by default with no loading option) - on a
slow connection neither the button nor the panel was visible.

Added a loading fallback in the same fixed position so there's no gap
between the button disappearing and the real panel appearing.
This commit is contained in:
Waleed
2026-07-13 09:10:33 -07:00
committed by GitHub
parent ef7c8e24b2
commit fc25cfb3c8
16 changed files with 372 additions and 280 deletions
+234
View File
@@ -0,0 +1,234 @@
'use client'
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
import { Streamdown } from 'streamdown'
import { cn } from '@/lib/utils'
import 'streamdown/styles.css'
interface DocSource {
title: string
url: string
}
/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */
function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] {
const seen = new Set<string>()
const sources: DocSource[] = []
for (const part of parts) {
if (part.type !== 'tool-searchDocs') continue
const output = (part as { output?: unknown }).output
if (!Array.isArray(output)) continue
for (const item of output as DocSource[]) {
if (!item?.url || seen.has(item.url)) continue
seen.add(item.url)
sources.push({ title: item.title, url: item.url })
}
}
return sources
}
/** Concatenate the streamed text parts of a message. */
function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string {
return parts
.filter((part) => part.type === 'text')
.map((part) => (part as unknown as { text: string }).text)
.join('')
}
interface AskAIPanelProps {
/** Active docs locale, forwarded so retrieval is scoped to the reader's language. */
locale: string
open: boolean
onClose: () => void
}
export function AskAIPanel({ locale, open, onClose }: AskAIPanelProps) {
const [input, setInput] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), [])
const { messages, sendMessage, status, stop, error } = useChat({ transport })
const isBusy = status === 'submitted' || status === 'streaming'
const handleClose = () => {
stop()
onClose()
}
useEffect(() => {
if (!open) return
textareaRef.current?.focus()
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose()
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open])
useEffect(() => {
if (!open) return
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [open])
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [messages])
const handleSubmit = (event: FormEvent) => {
event.preventDefault()
const text = input.trim()
if (!text || isBusy) return
sendMessage({ text }, { body: { locale } })
setInput('')
}
if (!open) return null
return (
<div
role='dialog'
aria-label='Ask Sim'
className='fixed right-4 bottom-4 z-50 flex h-[600px] max-h-[calc(100vh-2rem)] w-[400px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-5)] shadow-[var(--shadow-medium)] dark:bg-[var(--surface-4)]'
>
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
<span className='flex items-center gap-1.5 font-season text-[var(--text-body)] text-sm'>
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
Ask Sim
</span>
<button
type='button'
aria-label='Close'
onClick={handleClose}
className='flex size-7 items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
>
<X className='size-[16px]' />
</button>
</div>
<div ref={scrollRef} className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{messages.length === 0 && (
<p className='text-[var(--text-muted)] text-sm'>
Ask anything about building, deploying, and managing AI agents in Sim.
</p>
)}
{messages.map((message, index) => {
const text = getText(message.parts)
const isStreaming = isBusy && index === messages.length - 1
const sources = message.role === 'assistant' ? getSources(message.parts) : []
return (
<div
key={message.id}
className={cn(
'flex flex-col gap-1.5',
message.role === 'user' ? 'items-end' : 'items-start'
)}
>
{message.role === 'user' ? (
<div className='max-w-[85%] whitespace-pre-wrap rounded-[16px] bg-[var(--surface-5)] px-3 py-2 text-[var(--text-primary)] text-base leading-[23px]'>
{text}
</div>
) : (
<div className='max-w-full text-[var(--text-primary)] text-base'>
{text ? (
<Streamdown
className={cn(
'space-y-3 text-[var(--text-primary)] text-base leading-relaxed',
'[&_a]:text-[var(--text-primary)] [&_a]:underline [&_a]:decoration-dashed [&_a]:underline-offset-4',
'[&_strong]:font-[600]',
'[&_h1]:font-[600] [&_h2]:font-[600] [&_h3]:font-[600] [&_h4]:font-[600]',
'[&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5',
'[&_code]:font-mono [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-[var(--surface-5)] [&_pre]:p-3 [&_pre]:text-small'
)}
>
{text}
</Streamdown>
) : isStreaming ? (
'…'
) : sources.length === 0 ? (
<span className='text-[var(--text-muted)]'>No answer returned.</span>
) : null}
</div>
)}
{sources.length > 0 && (
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
{sources.map((source) => (
<a
key={source.url}
href={source.url}
target='_blank'
rel='noopener noreferrer'
className='rounded-lg border border-[var(--border-1)] px-2 py-0.5 text-[var(--text-muted)] text-xs transition-colors hover:bg-[var(--surface-active)]'
>
{source.title || source.url}
</a>
))}
</div>
)}
</div>
)
})}
{error && (
<p className='text-[var(--text-muted)] text-sm'>
Something went wrong. Please try again.
</p>
)}
</div>
<form onSubmit={handleSubmit} className='px-3 pb-3'>
<div className='flex items-end gap-2 rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-1.5 dark:bg-[var(--surface-5)]'>
<textarea
ref={textareaRef}
aria-label='Ask Sim about the docs'
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit(event)
}
}}
rows={1}
placeholder='Ask Sim about the docs…'
className='max-h-32 flex-1 resize-none bg-transparent py-1 font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
/>
{isBusy ? (
<button
type='button'
aria-label='Stop'
onClick={() => stop()}
className='flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-colors hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
>
<Square className='size-[12px] fill-white text-white dark:fill-black dark:text-black' />
</button>
) : (
<button
type='submit'
aria-label='Send'
disabled={!input.trim()}
className={cn(
'flex size-[28px] shrink-0 items-center justify-center rounded-full transition-colors',
input.trim()
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
: 'bg-[#808080]'
)}
>
<ArrowUp className='size-[16px] text-white dark:text-black' />
</button>
)}
</div>
</form>
</div>
)
}
+26 -200
View File
@@ -1,44 +1,20 @@
'use client'
import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
import { Streamdown } from 'streamdown'
import { cn } from '@/lib/utils'
import 'streamdown/styles.css'
import { useRef, useState } from 'react'
import { MessageCircle } from 'lucide-react'
import dynamic from 'next/dynamic'
interface DocSource {
title: string
url: string
}
/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */
function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] {
const seen = new Set<string>()
const sources: DocSource[] = []
for (const part of parts) {
if (part.type !== 'tool-searchDocs') continue
const output = (part as { output?: unknown }).output
if (!Array.isArray(output)) continue
for (const item of output as DocSource[]) {
if (!item?.url || seen.has(item.url)) continue
seen.add(item.url)
sources.push({ title: item.title, url: item.url })
}
}
return sources
}
/** Concatenate the streamed text parts of a message. */
function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string {
return parts
.filter((part) => part.type === 'text')
.map((part) => (part as unknown as { text: string }).text)
.join('')
}
const AskAIPanel = dynamic(() => import('./ask-ai-panel').then((m) => m.AskAIPanel), {
ssr: false,
loading: () => (
<div
aria-hidden
className='fixed right-4 bottom-4 z-50 flex size-11 items-center justify-center rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] shadow-[var(--shadow-medium)] dark:bg-[var(--surface-4)]'
>
<MessageCircle className='size-[16px] animate-pulse text-[var(--text-icon)]' />
</div>
),
})
interface AskAIProps {
/** Active docs locale, forwarded so retrieval is scoped to the reader's language. */
@@ -47,43 +23,27 @@ interface AskAIProps {
export function AskAI({ locale }: AskAIProps) {
const [open, setOpen] = useState(false)
const [input, setInput] = useState('')
const scrollRef = useRef<HTMLDivElement>(null)
const [hasOpened, setHasOpened] = useState(false)
const openButtonRef = useRef<HTMLButtonElement>(null)
// Stable transport; the locale is sent per-message (below) so it stays current
// after a language switch instead of being frozen into the transport.
const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), [])
const handleOpen = () => {
setHasOpened(true)
setOpen(true)
}
const { messages, sendMessage, status, stop, error } = useChat({ transport })
const isBusy = status === 'submitted' || status === 'streaming'
// Jump to the bottom instantly when the panel opens (a mount transition).
useEffect(() => {
if (!open) return
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight })
}, [open])
// Smooth-scroll as new messages stream in (an explicit re-orientation cue).
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' })
}, [messages])
const handleSubmit = (event: FormEvent) => {
event.preventDefault()
const text = input.trim()
if (!text || isBusy) return
sendMessage({ text }, { body: { locale } })
setInput('')
const handleClose = () => {
setOpen(false)
openButtonRef.current?.focus()
}
return (
<>
{!open && (
<button
ref={openButtonRef}
type='button'
aria-label='Ask Sim'
onClick={() => setOpen(true)}
onClick={handleOpen}
className='fixed right-4 bottom-4 z-50 flex h-11 items-center gap-1.5 rounded-full border border-[var(--border-1)] bg-[var(--surface-5)] px-4 font-season text-[var(--text-body)] text-sm shadow-[var(--shadow-medium)] transition-colors hover:bg-[var(--surface-active)] dark:bg-[var(--surface-4)]'
>
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
@@ -91,141 +51,7 @@ export function AskAI({ locale }: AskAIProps) {
</button>
)}
{open && (
<div className='fixed right-4 bottom-4 z-50 flex h-[600px] max-h-[calc(100vh-2rem)] w-[400px] max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-[var(--border-1)] bg-[var(--surface-5)] shadow-[var(--shadow-medium)] dark:bg-[var(--surface-4)]'>
<div className='flex items-center justify-between border-[var(--border-1)] border-b px-4 py-3'>
<span className='flex items-center gap-1.5 font-season text-[var(--text-body)] text-sm'>
<MessageCircle className='size-[16px] text-[var(--text-icon)]' />
Ask Sim
</span>
<button
type='button'
aria-label='Close'
onClick={() => {
stop()
setOpen(false)
}}
className='flex size-7 items-center justify-center rounded-lg text-[var(--text-icon)] transition-colors hover:bg-[var(--surface-active)]'
>
<X className='size-[16px]' />
</button>
</div>
<div ref={scrollRef} className='flex-1 space-y-4 overflow-y-auto px-4 py-4'>
{messages.length === 0 && (
<p className='text-[var(--text-muted)] text-sm'>
Ask anything about building, deploying, and managing AI agents in Sim.
</p>
)}
{messages.map((message, index) => {
const text = getText(message.parts)
// Only the in-progress (last) message should show the loading state.
const isStreaming = isBusy && index === messages.length - 1
const sources = message.role === 'assistant' ? getSources(message.parts) : []
return (
<div
key={message.id}
className={cn(
'flex flex-col gap-1.5',
message.role === 'user' ? 'items-end' : 'items-start'
)}
>
{message.role === 'user' ? (
<div className='max-w-[85%] whitespace-pre-wrap rounded-[16px] bg-[var(--surface-5)] px-3 py-2 text-[var(--text-primary)] text-base leading-[23px]'>
{text}
</div>
) : (
<div className='max-w-full text-[var(--text-primary)] text-base'>
{text ? (
<Streamdown
className={cn(
'space-y-3 text-[var(--text-primary)] text-base leading-relaxed',
'[&_a]:text-[var(--text-primary)] [&_a]:underline [&_a]:decoration-dashed [&_a]:underline-offset-4',
'[&_strong]:font-[600]',
'[&_h1]:font-[600] [&_h2]:font-[600] [&_h3]:font-[600] [&_h4]:font-[600]',
'[&_li]:my-1 [&_ol]:my-3 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:my-3 [&_ul]:list-disc [&_ul]:pl-5',
'[&_code]:font-mono [&_pre]:my-3 [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-[var(--surface-5)] [&_pre]:p-3 [&_pre]:text-small'
)}
>
{text}
</Streamdown>
) : isStreaming ? (
'…'
) : sources.length === 0 ? (
<span className='text-[var(--text-muted)]'>No answer returned.</span>
) : null}
</div>
)}
{sources.length > 0 && (
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
{sources.map((source) => (
<a
key={source.url}
href={source.url}
target='_blank'
rel='noopener noreferrer'
className='rounded-lg border border-[var(--border-1)] px-2 py-0.5 text-[var(--text-muted)] text-xs transition-colors hover:bg-[var(--surface-active)]'
>
{source.title || source.url}
</a>
))}
</div>
)}
</div>
)
})}
{error && (
<p className='text-[var(--text-muted)] text-sm'>
Something went wrong. Please try again.
</p>
)}
</div>
<form onSubmit={handleSubmit} className='px-3 pb-3'>
<div className='flex items-end gap-2 rounded-2xl border border-[var(--border-1)] bg-white px-2.5 py-1.5 dark:bg-[var(--surface-5)]'>
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
handleSubmit(event)
}
}}
rows={1}
placeholder='Ask Sim about the docs…'
className='max-h-32 flex-1 resize-none bg-transparent py-1 font-season text-[var(--text-body)] text-sm outline-none placeholder:text-[var(--text-muted)]'
/>
{isBusy ? (
<button
type='button'
aria-label='Stop'
onClick={() => stop()}
className='flex size-[28px] shrink-0 items-center justify-center rounded-full bg-[#383838] transition-colors hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
>
<Square className='size-[12px] fill-white text-white dark:fill-black dark:text-black' />
</button>
) : (
<button
type='submit'
aria-label='Send'
disabled={!input.trim()}
className={cn(
'flex size-[28px] shrink-0 items-center justify-center rounded-full transition-colors',
input.trim()
? 'bg-[#383838] hover:bg-[#575757] dark:bg-[#e0e0e0] dark:hover:bg-[#cfcfcf]'
: 'bg-[#808080]'
)}
>
<ArrowUp className='size-[16px] text-white dark:text-black' />
</button>
)}
</div>
</form>
</div>
)}
{hasOpened && <AskAIPanel locale={locale} open={open} onClose={handleClose} />}
</>
)
}
@@ -2,6 +2,7 @@
import { type ReactNode, useState } from 'react'
import type { Folder, Item, Separator } from 'fumadocs-core/page-tree'
import { useSidebar } from 'fumadocs-ui/components/sidebar/base'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { i18n } from '@/lib/i18n'
@@ -66,11 +67,13 @@ const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]'
export function SidebarItem({ item }: { item: Item }) {
const pathname = usePathname()
const { prefetch } = useSidebar()
const active = isActive(item.url, pathname, false)
return (
<Link
href={item.url}
prefetch={prefetch}
data-active={active}
className={cn(
ITEM_BASE,
@@ -97,6 +100,7 @@ function isApiReferenceFolder(node: Folder): boolean {
export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) {
const pathname = usePathname()
const { prefetch } = useSidebar()
const hasActiveChild = checkHasActiveChild(item, pathname)
const isApiRef = isApiReferenceFolder(item)
const isOnApiRefPage = stripLangPrefix(pathname).startsWith('/api-reference')
@@ -111,6 +115,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
return (
<Link
href={item.index.url}
prefetch={prefetch}
data-active={active}
className={cn(
ITEM_BASE,
@@ -133,6 +138,7 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac
<>
<Link
href={item.index.url}
prefetch={prefetch}
data-active={active}
className={cn(
'flex flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors',
+1
View File
@@ -74,6 +74,7 @@ export function Navbar() {
<Link
key={tab.label}
href={tab.href}
aria-current={isActive ? 'page' : undefined}
{...(tab.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className={cn(
'-mb-px relative flex items-center border-b text-[14px] tracking-[-0.01em] transition-colors',
+2 -2
View File
@@ -89,7 +89,7 @@ export function StructuredData({
},
featureList: [
'AI workspace for teams',
'Mothership — natural language agent creation',
'Chat — natural language agent creation',
'Visual workflow builder',
'1,000+ integrations',
'LLM orchestration (OpenAI, Anthropic, Google, xAI, Mistral, Perplexity)',
@@ -115,7 +115,7 @@ export function StructuredData({
}}
/>
)}
{url === baseUrl && (
{(url === baseUrl || url === `${baseUrl}/`) && (
<script
type='application/ld+json'
dangerouslySetInnerHTML={{
+1 -1
View File
@@ -50,7 +50,7 @@ export function Heading({ as, className, ...props }: HeadingProps) {
) : (
<Link
aria-hidden
className='size-[14px] shrink-0 text-[var(--text-icon)] opacity-0 transition-opacity group-hover:opacity-100 peer-hover:opacity-100'
className='size-[14px] shrink-0 text-[var(--text-icon)] opacity-0 transition-opacity group-hover:opacity-100 peer-hover:opacity-100 peer-focus-visible:opacity-100'
/>
)}
</As>
+8
View File
@@ -16,6 +16,7 @@ export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: Lightbo
const overlayRef = useRef<HTMLDivElement>(null)
const mediaButtonRef = useRef<HTMLButtonElement>(null)
const videoRef = useRef<HTMLVideoElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
const closeLightbox = useEffectEvent(onClose)
useEffect(() => {
@@ -24,6 +25,11 @@ export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: Lightbo
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeLightbox()
return
}
if (event.key === 'Tab') {
event.preventDefault()
mediaButtonRef.current?.focus()
}
}
@@ -35,6 +41,7 @@ export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: Lightbo
const previousOverflow = document.body.style.overflow
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('click', handleClickOutside)
document.body.style.overflow = 'hidden'
@@ -44,6 +51,7 @@ export function Lightbox({ isOpen, onClose, src, alt, type, startTime }: Lightbo
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('click', handleClickOutside)
document.body.style.overflow = previousOverflow
previouslyFocusedRef.current?.focus()
}
}, [isOpen])
+16 -2
View File
@@ -106,8 +106,17 @@ export function ResponseSection({ children }: ResponseSectionProps) {
setIsOpen(false)
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleKeyDown)
}
}, [])
return (
@@ -120,6 +129,9 @@ export function ResponseSection({ children }: ResponseSectionProps) {
<button
type='button'
className='response-section-dropdown-trigger'
aria-haspopup='listbox'
aria-expanded={isOpen}
aria-label={`Response status code, currently ${selectedCode}`}
onClick={() => setIsOpen(!isOpen)}
>
<span>{selectedCode}</span>
@@ -131,11 +143,13 @@ export function ResponseSection({ children }: ResponseSectionProps) {
/>
</button>
{isOpen && (
<div className='response-section-dropdown-menu'>
<div className='response-section-dropdown-menu' role='listbox'>
{statusCodes.map((code) => (
<button
key={code}
type='button'
role='option'
aria-selected={code === selectedCode}
className={cn(
'response-section-dropdown-item',
code === selectedCode && 'response-section-dropdown-item-selected'
+27 -3
View File
@@ -1,6 +1,6 @@
'use client'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { cn, getAssetUrl } from '@/lib/utils'
import { Lightbox } from './lightbox'
@@ -30,6 +30,29 @@ export function Video({
const videoRef = useRef<HTMLVideoElement>(null)
const startTimeRef = useRef(0)
const [isLightboxOpen, setIsLightboxOpen] = useState(false)
const [isInView, setIsInView] = useState(false)
useEffect(() => {
const el = videoRef.current
if (!el) return
if (typeof IntersectionObserver === 'undefined') {
setIsInView(true)
return
}
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsInView(true)
observer.disconnect()
}
},
{ rootMargin: '200px' }
)
observer.observe(el)
return () => observer.disconnect()
}, [])
const openLightbox = () => {
startTimeRef.current = videoRef.current?.currentTime ?? 0
@@ -39,17 +62,18 @@ export function Video({
const video = (
<video
ref={videoRef}
autoPlay={autoPlay}
autoPlay={isInView && autoPlay}
loop={loop}
muted={muted}
playsInline={playsInline}
preload='none'
width={width}
height={height}
className={cn(
className,
enableLightbox && 'cursor-pointer transition-opacity group-hover:opacity-[0.97]'
)}
src={getAssetUrl(src)}
src={isInView ? getAssetUrl(src) : undefined}
/>
)
@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { domAnimation, LazyMotion, m } from 'framer-motion'
import { Maximize2, X } from 'lucide-react'
import ReactFlow, {
@@ -253,6 +253,8 @@ export function WorkflowPreview({
}: WorkflowPreviewProps) {
const [expanded, setExpanded] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const closeButtonRef = useRef<HTMLButtonElement>(null)
const previouslyFocusedRef = useRef<HTMLElement | null>(null)
useEffect(() => {
if (!expanded) return
@@ -261,12 +263,15 @@ export function WorkflowPreview({
}
document.addEventListener('keydown', onKey)
const previousOverflow = document.body.style.overflow
previouslyFocusedRef.current = document.activeElement as HTMLElement | null
document.body.style.overflow = 'hidden'
document.body.classList.add('wp-lightbox-open')
closeButtonRef.current?.focus()
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = previousOverflow
document.body.classList.remove('wp-lightbox-open')
previouslyFocusedRef.current?.focus()
}
}, [expanded])
@@ -322,6 +327,7 @@ export function WorkflowPreview({
<div className='pointer-events-none absolute top-0 right-0 left-0 z-10 flex items-center justify-between px-4 py-3'>
<span className='text-[13px] text-[var(--text-muted)]'>{workflow.name}</span>
<button
ref={closeButtonRef}
type='button'
aria-label='Close'
onClick={() => setExpanded(false)}