diff --git a/apps/docs/app/[[...slug]]/page.tsx b/apps/docs/app/[[...slug]]/page.tsx index 02f782880e6..4d94308ae0e 100644 --- a/apps/docs/app/[[...slug]]/page.tsx +++ b/apps/docs/app/[[...slug]]/page.tsx @@ -1,13 +1,15 @@ import type React from 'react' +import { Children, cloneElement, isValidElement } from 'react' import { highlight } from 'fumadocs-core/highlight' import { findNeighbour } from 'fumadocs-core/page-tree' import type { ApiPageProps } from 'fumadocs-openapi/ui' import { createAPIPage } from 'fumadocs-openapi/ui' import { Pre } from 'fumadocs-ui/components/codeblock' import defaultMdxComponents from 'fumadocs-ui/mdx' -import { DocsBody, DocsPage, DocsTitle } from 'fumadocs-ui/page' +import { DocsBody, DocsPage } from 'fumadocs-ui/page' import { notFound } from 'next/navigation' import { PageFooter } from '@/components/docs-layout/page-footer' +import { PageHeader } from '@/components/docs-layout/page-header' import { PageNavigationArrows } from '@/components/docs-layout/page-navigation-arrows' import { LLMCopyButton } from '@/components/page-actions' import { StructuredData } from '@/components/structured-data' @@ -51,6 +53,24 @@ async function ApiCodeBlock({ lang, code }: { lang: string; code: string }) { ) } +interface ApiSlotElementProps extends React.HTMLAttributes { + items?: unknown[] +} + +/** Labels Fumadocs auth selectors while retaining their selection state and content. */ +function labelAuthSelectors(node: React.ReactNode): React.ReactNode { + if (!isValidElement(node)) return node + + const props = node.props + const children = + props.children === undefined ? undefined : Children.map(props.children, labelAuthSelectors) + + if (Array.isArray(props.items)) { + return cloneElement(node, { 'aria-label': 'Authentication method' }, children) + } + return children === undefined ? node : cloneElement(node, undefined, children) +} + const APIPage = createAPIPage(openapi, { renderCodeBlock: (props) => , playground: { enabled: false }, @@ -68,12 +88,14 @@ const APIPage = createAPIPage(openapi, { content: { renderOperationLayout: (slots) => { return ( -
+
{slots.header} {slots.description} {slots.apiPlayground} - {slots.authSchemes &&
{slots.authSchemes}
} + {slots.authSchemes && ( +
{labelAuthSelectors(slots.authSchemes)}
+ )} {slots.parameters} {slots.body &&
{slots.body}
} {slots.responses} @@ -179,6 +201,8 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> breadcrumb={breadcrumbs} /> component: footer, }} > -
-
-
- -
- + +
+
- {data.title} -
+ + @@ -225,6 +246,8 @@ export default async function Page(props: { params: Promise<{ slug?: string[] }> breadcrumb={breadcrumbs} /> component: footer, }} > -
-
-
- -
- + +
+
- {data.title} -
+ + + + Skip to content + , + title: ( + <> + + Sim documentation home + + ), }} sidebar={{ tabs: false, @@ -74,14 +82,18 @@ export default function RootLayout({ children }: { children: ReactNode }) { footer: null, banner: null, prefetch: false, - components: { - Item: SidebarItem, - Folder: SidebarFolder, - Separator: SidebarSeparator, + }} + slots={{ + sidebar: { + provider: SidebarProvider, + root: DocsSidebar, + trigger: SidebarTrigger, + useSidebar, }, }} containerProps={{ - className: '!pt-0', + className: + '!pt-0 [--text-muted:var(--text-secondary)] [--color-fd-muted-foreground:var(--text-secondary)]', }} > {children} diff --git a/apps/docs/app/not-found.tsx b/apps/docs/app/not-found.tsx index d504a63db8b..a282607e005 100644 --- a/apps/docs/app/not-found.tsx +++ b/apps/docs/app/not-found.tsx @@ -7,13 +7,13 @@ export const metadata = { export default function NotFound() { return ( - +

Page not found

-

+

The page you're looking for doesn't exist or has been moved.

diff --git a/apps/docs/components/docs-layout/docs-sidebar.test.tsx b/apps/docs/components/docs-layout/docs-sidebar.test.tsx new file mode 100644 index 00000000000..a0a51057b11 --- /dev/null +++ b/apps/docs/components/docs-layout/docs-sidebar.test.tsx @@ -0,0 +1,119 @@ +/** @vitest-environment jsdom */ +import { act, type ComponentProps, createElement, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { DocsSidebar } from '@/components/docs-layout/docs-sidebar' + +const state = vi.hoisted(() => ({ mobile: false, open: false, setOpen: vi.fn() })) +vi.mock('fumadocs-core/utils/use-media-query', () => ({ useMediaQuery: () => state.mobile })) +vi.mock('fumadocs-ui/components/sidebar/base', () => ({ useSidebar: () => state })) +vi.mock('next/navigation', () => ({ usePathname: () => '/integrations/zendesk' })) +vi.mock('fumadocs-ui/contexts/tree', () => ({ + useTreeContext: () => ({ + root: { + $id: 'docs', + children: [{ type: 'page', name: 'Zendesk', url: '/integrations/zendesk' }], + }, + }), +})) +vi.mock('@/components/docs-layout/sidebar-components', () => ({ + SidebarItem: ({ item }: { item: { name: string; url: string } }) => + createElement('a', { href: item.url, 'aria-current': 'page' }, item.name), + SidebarFolder: ({ children }: { children: ReactNode }) => children, + SidebarSeparator: () => null, +})) +vi.mock('@sim/emcn', () => ({ + cn: (...classes: string[]) => classes.join(' '), + Chip: ({ leftIcon: _icon, ...props }: ComponentProps<'button'> & { leftIcon?: unknown }) => + createElement('button', props), + ChipLink: ({ onNavigate, ...props }: ComponentProps<'a'> & { onNavigate?: () => void }) => + createElement('a', { + ...props, + onClick: (event) => { + event.preventDefault() + onNavigate?.() + }, + }), + useScrollEdges: () => ({ top: false, bottom: false }), + scrollFadeAttributes: () => ({}), + scrollFadeClass: 'scroll-fade', +})) +vi.mock('@sim/emcn/icons', () => ({ X: () => null })) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + state.mobile = false + state.open = false + state.setOpen.mockClear() + HTMLDialogElement.prototype.showModal = function () { + this.open = true + } + HTMLDialogElement.prototype.close = function () { + this.open = false + } + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function render() { + act(() => root.render()) +} + +describe('documentation sidebar surfaces', () => { + it('renders one desktop navigation tree', () => { + render() + expect(container.querySelectorAll('aside[aria-label="Documentation navigation"]')).toHaveLength( + 1 + ) + expect(container.querySelectorAll('a[aria-current="page"]')).toHaveLength(1) + expect(container.querySelector('dialog')).toBeNull() + }) + + it('opens the mobile modal and synchronizes native dismissal', () => { + state.mobile = true + state.open = true + render() + const dialog = container.querySelector('dialog')! + expect(dialog.open).toBe(true) + expect(container.querySelector('aside')).toBeNull() + expect(container.querySelectorAll('a[aria-current="page"]')).toHaveLength(1) + act(() => dialog.dispatchEvent(new Event('close'))) + expect(state.setOpen).toHaveBeenCalledWith(false) + }) + + it('clears mobile disclosure state when resizing to desktop', () => { + state.mobile = true + state.open = true + render() + state.mobile = false + render() + expect(container.querySelector('dialog')).toBeNull() + expect(state.setOpen).toHaveBeenCalledWith(false) + }) + + it('scrolls a deep selected row into view without scrolling the document', () => { + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function ( + this: Element + ) { + return DOMRect.fromRect( + this.hasAttribute('aria-current') + ? { x: 0, y: 800, width: 250, height: 30 } + : { x: 0, y: 0, width: 280, height: 600 } + ) + }) + render() + expect(container.querySelector('.scroll-fade')?.scrollTop).toBe(242) + expect(document.documentElement.scrollTop).toBe(0) + }) +}) diff --git a/apps/docs/components/docs-layout/docs-sidebar.tsx b/apps/docs/components/docs-layout/docs-sidebar.tsx new file mode 100644 index 00000000000..f2d68258adb --- /dev/null +++ b/apps/docs/components/docs-layout/docs-sidebar.tsx @@ -0,0 +1,128 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { Chip, ChipLink, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' +import { X } from '@sim/emcn/icons' +import type { Node } from 'fumadocs-core/page-tree' +import { useMediaQuery } from 'fumadocs-core/utils/use-media-query' +import { useSidebar } from 'fumadocs-ui/components/sidebar/base' +import { useTreeContext } from 'fumadocs-ui/contexts/tree' +import { usePathname } from 'next/navigation' +import { + SidebarFolder, + SidebarItem, + SidebarSeparator, +} from '@/components/docs-layout/sidebar-components' +import { cn } from '@/lib/utils' + +interface SidebarTreeProps { + nodes: Node[] +} + +function SidebarTree({ nodes }: SidebarTreeProps) { + return nodes.map((node, index) => { + if (node.type === 'separator') return + if (node.type === 'folder') { + return ( + + + + ) + } + return + }) +} + +function SidebarScrollArea() { + const ref = useRef(null) + const edges = useScrollEdges(ref) + const { root } = useTreeContext() + const pathname = usePathname() + + useEffect(() => { + const viewport = ref.current + const current = viewport?.querySelector('[aria-current="page"]') + if (!viewport || !current) return + const bounds = viewport.getBoundingClientRect() + const row = current.getBoundingClientRect() + if (row.top < bounds.top) viewport.scrollTop += row.top - bounds.top - 12 + else if (row.bottom > bounds.bottom) viewport.scrollTop += row.bottom - bounds.bottom + 12 + }, [pathname]) + + return ( +
+
+ +
+
+ ) +} + +/** The native modal supplies focus containment, Escape dismissal, and focus restoration. */ +export function DocsSidebar() { + const dialogRef = useRef(null) + const { open, setOpen } = useSidebar() + const mobile = useMediaQuery('(width < 1024px)') + + useEffect(() => { + const dialog = dialogRef.current + if (!mobile && open) { + setOpen(false) + return + } + if (open && mobile) dialog?.showModal() + else dialog?.close() + }, [open, mobile, setOpen]) + + if (mobile) { + return ( + setOpen(false)} + onClick={(event) => { + if (event.target !== event.currentTarget) return + const bounds = event.currentTarget.getBoundingClientRect() + if (event.clientX < bounds.left || event.clientX > bounds.right) setOpen(false) + }} + className='fixed inset-y-0 right-0 left-auto m-0 h-dvh max-h-none w-[85%] max-w-[380px] border-[var(--border)] border-l bg-[var(--surface-1)] p-0 text-[var(--text-body)] backdrop:bg-black/30 backdrop:backdrop-blur-xs open:flex open:flex-col' + > +
+ Documentation + setOpen(false)} /> +
+ + {open && } +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/apps/docs/components/docs-layout/page-header.tsx b/apps/docs/components/docs-layout/page-header.tsx new file mode 100644 index 00000000000..9b3b3b20195 --- /dev/null +++ b/apps/docs/components/docs-layout/page-header.tsx @@ -0,0 +1,20 @@ +import type { ReactNode } from 'react' +import { cn } from '@sim/emcn' +import { DocsTitle } from 'fumadocs-ui/page' + +interface PageHeaderProps { + title: string + children: ReactNode + className?: string +} + +export function PageHeader({ title, children, className }: PageHeaderProps) { + return ( +
+ + {title} + +
{children}
+
+ ) +} diff --git a/apps/docs/components/docs-layout/sidebar-components.test.tsx b/apps/docs/components/docs-layout/sidebar-components.test.tsx new file mode 100644 index 00000000000..57e926197c1 --- /dev/null +++ b/apps/docs/components/docs-layout/sidebar-components.test.tsx @@ -0,0 +1,221 @@ +/** @vitest-environment jsdom */ +import { act, type ComponentProps, createElement, type ReactNode } from 'react' +import type { Folder } from 'fumadocs-core/page-tree' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SidebarFolder, SidebarItem } from '@/components/docs-layout/sidebar-components' + +const navigation = vi.hoisted(() => ({ pathname: '/search', open: false, setOpen: vi.fn() })) +vi.mock('next/navigation', () => ({ usePathname: () => navigation.pathname })) +vi.mock('fumadocs-ui/components/sidebar/base', () => ({ + useSidebar: () => ({ prefetch: false, open: navigation.open, setOpen: navigation.setOpen }), +})) +vi.mock('@sim/emcn', () => ({ + cn: vi.fn(() => ''), + ChipLink: ({ + active: _active, + fullWidth: _fullWidth, + prefetch: _prefetch, + rightAdornment, + children, + onNavigate, + ...props + }: ComponentProps<'a'> & { + active?: boolean + fullWidth?: boolean + prefetch?: boolean + rightAdornment?: ReactNode + onNavigate?: (event: { preventDefault: () => void }) => void + }) => + createElement( + 'a', + { + ...props, + onClick: (event) => { + event.preventDefault() + if (!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey) + onNavigate?.({ preventDefault: vi.fn() }) + }, + }, + children, + rightAdornment + ), + Chip: ({ + fullWidth: _fullWidth, + rightAdornment, + children, + ...props + }: ComponentProps<'button'> & { + fullWidth?: boolean + rightAdornment?: ReactNode + }) => createElement('button', { type: 'button', ...props }, children, rightAdornment), +})) +vi.mock('@sim/emcn/icons', () => ({ + ChevronRight: (props: ComponentProps<'svg'>) => createElement('svg', props), +})) + +const airtable: Folder = { + type: 'folder', + name: 'Airtable', + index: { type: 'page', name: 'Airtable', url: '/integrations/airtable' }, + children: [ + { type: 'page', name: 'Personal Access Tokens', url: '/integrations/airtable-service-account' }, + ], +} +let container: HTMLDivElement +let root: Root +function renderFolder(item: Folder = airtable) { + act(() => + root.render( + + Guide + + ) + ) +} +function row() { + return container.querySelector('[aria-expanded]')! +} +function clickRow() { + act(() => row().click()) + return row() +} + +beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + navigation.pathname = '/search' + navigation.open = false + navigation.setOpen.mockClear() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('sidebar navigation', () => { + it('uses one overview link and chevron with no separate button or duplicate children', () => { + renderFolder() + expect(row().getAttribute('href')).toBe('/integrations/airtable') + expect(row().querySelector('svg')).not.toBeNull() + expect(container.querySelector('button')).toBeNull() + expect(row().getAttribute('aria-expanded')).toBe('false') + const content = document.getElementById(row().getAttribute('aria-controls')!)! + expect(content.querySelectorAll('a')).toHaveLength(1) + expect(content.hasAttribute('inert')).toBe(true) + expect(content.getAttribute('aria-hidden')).toBe('true') + navigation.pathname = '/integrations/airtable' + renderFolder() + expect(content.hasAttribute('inert')).toBe(false) + expect(content.getAttribute('aria-hidden')).toBe('false') + }) + + it('opens canonical ancestors for a guide without nested URL segments', () => { + navigation.pathname = '/integrations/airtable-service-account' + renderFolder() + expect(row().getAttribute('aria-expanded')).toBe('true') + expect(row().hasAttribute('aria-current')).toBe(false) + renderFolder({ type: 'folder', name: 'Integrations', children: [airtable] }) + expect(row().getAttribute('aria-expanded')).toBe('true') + }) + + it('does not open an unrelated section based on a relocated guide URL prefix', () => { + navigation.pathname = '/workflows/blocks/logs' + renderFolder({ + type: 'folder', + name: 'Integrations', + children: [ + { + type: 'folder', + name: 'Logs', + index: { type: 'page', name: 'Logs', url: '/integrations/logs' }, + children: [{ type: 'page', name: 'Using Logs in Workflows', url: navigation.pathname }], + }, + ], + }) + expect(row().getAttribute('aria-expanded')).toBe('true') + renderFolder({ + type: 'folder', + name: 'Workflows', + index: { type: 'page', name: 'Workflows', url: '/workflows' }, + children: [{ type: 'page', name: 'Core Blocks', url: '/workflows/blocks' }], + }) + expect(row().getAttribute('aria-expanded')).toBe('false') + }) + + it('toggles the current overview from the same row, including its chevron', () => { + navigation.pathname = '/integrations/airtable' + renderFolder() + expect(row().getAttribute('aria-current')).toBe('page') + expect(clickRow().getAttribute('aria-expanded')).toBe('false') + act(() => + row() + .querySelector('svg')! + .dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })) + ) + expect(row().getAttribute('aria-expanded')).toBe('true') + }) + + it('preserves open-in-new-tab clicks without toggling the folder', () => { + navigation.pathname = '/integrations/airtable' + renderFolder() + act(() => + row().dispatchEvent( + new MouseEvent('click', { bubbles: true, cancelable: true, metaKey: true }) + ) + ) + expect(row().getAttribute('aria-expanded')).toBe('true') + }) + + it('clears manual expansion on navigation so back navigation opens the active section', () => { + navigation.pathname = '/integrations/airtable' + renderFolder() + expect(clickRow().getAttribute('aria-expanded')).toBe('false') + navigation.pathname = '/files' + renderFolder() + navigation.pathname = '/integrations/airtable' + renderFolder() + expect(row().getAttribute('aria-expanded')).toBe('true') + }) + + it('closes the mobile drawer on same-page navigation', () => { + navigation.open = true + navigation.pathname = '/integrations/airtable' + renderFolder() + clickRow() + expect(navigation.setOpen).toHaveBeenCalledWith(false) + expect(row().getAttribute('aria-expanded')).toBe('true') + }) + + it('renders an overview-only folder as a single link without disclosure semantics', () => { + navigation.pathname = '/integrations/airtable' + act(() => + root.render({null}) + ) + expect(container.querySelectorAll('a')).toHaveLength(1) + expect(container.querySelector('[aria-expanded],button')).toBeNull() + expect(container.querySelector('a')?.getAttribute('aria-current')).toBe('page') + }) + + it('keeps folders without an overview as native disclosure buttons', () => { + renderFolder({ type: 'folder', name: 'Shared credential guides', children: airtable.children }) + expect(row().tagName).toBe('BUTTON') + expect(clickRow().getAttribute('aria-expanded')).toBe('true') + }) + + it('marks only the exact current page and closes the drawer for leaf links', () => { + const item = airtable.children[0] + if (item.type !== 'page') throw new Error('Expected page fixture') + navigation.pathname = item.url + act(() => root.render(createElement(SidebarItem, { item }))) + expect(container.querySelector('a')?.getAttribute('aria-current')).toBe('page') + act(() => container.querySelector('a')!.click()) + expect(navigation.setOpen).toHaveBeenCalledWith(false) + navigation.pathname = `${item.url}/other` + act(() => root.render(createElement(SidebarItem, { item }))) + expect(container.querySelector('a')?.getAttribute('aria-current')).toBeNull() + }) +}) diff --git a/apps/docs/components/docs-layout/sidebar-components.tsx b/apps/docs/components/docs-layout/sidebar-components.tsx index 2e4112f69f4..34a516fbbf6 100644 --- a/apps/docs/components/docs-layout/sidebar-components.tsx +++ b/apps/docs/components/docs-layout/sidebar-components.tsx @@ -1,158 +1,119 @@ 'use client' -import { type ReactNode, useState } from 'react' -import { chipActiveSurfaceClass, chipHoverSurfaceClass } from '@sim/emcn' +import { type ReactNode, useId, useState } from 'react' +import { Chip, ChipLink } from '@sim/emcn' import { ChevronRight } from '@sim/emcn/icons' 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 { cn } from '@/lib/utils' -function SidebarChevron({ open, className }: { open: boolean; className?: string }) { - return ( - - ) +interface SidebarItemProps { + item: Item } -function isActive(url: string, pathname: string, nested = true): boolean { - return url === pathname || (nested && pathname.startsWith(`${url}/`)) +interface SidebarFolderProps { + item: Folder + children: ReactNode } -/** - * Rows mirror the app sidebar's chip pill: 30px tall, `rounded-lg`, `px-2`, 14px - * at normal weight, `--text-body` at rest AND when active — only the background - * moves, on the two-surface model — see emcn's `chipHoverSurfaceClass`. - * - * Height, horizontal padding, weight and color are additionally pinned in - * `global.css` (`html #nd-sidebar a…`), which needs `!important` to beat - * fumadocs' own sidebar rules and therefore also beats these utilities. Keep the - * two in step: the classes here describe the intent and drive the mobile layout, - * the stylesheet is what actually lands on desktop. - */ -const ITEM_BASE = - 'flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[var(--text-body)] text-sm transition-colors' -const ITEM_ACTIVE_MOBILE = chipActiveSurfaceClass - -const ITEM_DESKTOP = - 'lg:mb-[0.0625rem] lg:block lg:rounded-lg lg:px-2 lg:font-normal lg:text-sm lg:leading-tight' -const ITEM_TEXT = 'lg:text-[var(--text-body)]' -/** - * Unprefixed, and applied only to inactive rows — an unconditional hover in - * `ITEM_BASE` would fade the current page under the pointer below `lg`. - */ -const ITEM_HOVER = chipHoverSurfaceClass -const ITEM_ACTIVE = 'lg:bg-[var(--surface-active)] lg:font-normal lg:text-[var(--text-body)]' - -const FOLDER_TEXT = 'lg:text-[var(--text-body)] lg:font-normal' -const FOLDER_HOVER = chipHoverSurfaceClass -const FOLDER_ACTIVE = 'lg:bg-[var(--surface-active)] lg:text-[var(--text-body)]' - -const itemClass = (active: boolean) => - cn(ITEM_BASE, ITEM_DESKTOP, ITEM_TEXT, active ? cn(ITEM_ACTIVE_MOBILE, ITEM_ACTIVE) : ITEM_HOVER) +interface SidebarSeparatorProps { + item: Separator +} -export function SidebarItem({ item }: { item: Item }) { +export function SidebarItem({ item }: SidebarItemProps) { const pathname = usePathname() - const { prefetch } = useSidebar() - const active = isActive(item.url, pathname, false) + const { prefetch, setOpen } = useSidebar() + const active = item.url === pathname return ( - + { + setOpen(false) + }} + > {item.name} - + ) } -export function SidebarFolder({ item, children }: { item: Folder; children: ReactNode }) { +/** A section is one link: navigate to its overview, then toggle it on subsequent clicks. */ +export function SidebarFolder({ item, children }: SidebarFolderProps) { + const contentId = useId() const pathname = usePathname() - const { prefetch } = useSidebar() - const hasActiveChild = checkHasActiveChild(item, pathname) - const hasChildren = item.children.length > 0 - const defaultOpen = hasActiveChild + const { prefetch, open: drawerOpen, setOpen } = useSidebar() const [manualOpen, setManualOpen] = useState<{ pathname: string; open: boolean } | null>(null) - const open = manualOpen?.pathname === pathname ? manualOpen.open : defaultOpen + if (manualOpen && manualOpen.pathname !== pathname) setManualOpen(null) + const hasChildren = item.children.length > 0 + const active = item.index?.url === pathname + const open = + manualOpen?.pathname === pathname ? manualOpen.open : hasActiveDescendant(item, pathname) const toggleOpen = () => setManualOpen({ pathname, open: !open }) - const active = item.index ? isActive(item.index.url, pathname, false) : false - - if (item.index && !hasChildren) { - return ( - - {item.name} - - ) - } + const chevron = hasChildren ? ( + + ) : undefined return ( -
-
- {item.index ? ( - <> - - {item.name} - - {hasChildren && ( - - )} - - ) : ( - - )} -
+
+ {item.index ? ( + { + if (drawerOpen) { + setManualOpen(null) + setOpen(false) + } else if (active && hasChildren) { + event.preventDefault() + toggleOpen() + } else { + setManualOpen(null) + } + }} + > + {item.name} + + ) : ( + + {item.name} + + )} {hasChildren && (
-
{children}
-
    {children}
+
{children}
)} @@ -160,33 +121,19 @@ export function SidebarFolder({ item, children }: { item: Folder; children: Reac ) } -/** - * Group label. Mirrors the app sidebar's section header: a 12px `--text-muted` - * row at normal weight in sentence case, with the group's 16px top gap carried - * by the label itself (the app's `SIDEBAR_SECTION_GAP_CLASS`). Groups are told - * apart by that gap alone — the app draws no rule between them. - */ -export function SidebarSeparator({ item }: { item: Separator }) { +export function SidebarSeparator({ item }: SidebarSeparatorProps) { return (
-

{item.name}

+

{item.name}

) } -function checkHasActiveChild(node: Folder, pathname: string): boolean { - if (node.index && isActive(node.index.url, pathname)) { - return true - } - - for (const child of node.children) { - if (child.type === 'page' && isActive(child.url, pathname)) { - return true - } - if (child.type === 'folder' && checkHasActiveChild(child, pathname)) { - return true - } - } - - return false +function hasActiveDescendant(node: Folder, pathname: string): boolean { + if (node.index?.url === pathname) return true + return node.children.some((child) => + child.type === 'page' + ? child.url === pathname + : child.type === 'folder' && hasActiveDescendant(child, pathname) + ) } diff --git a/apps/docs/components/footer/footer.tsx b/apps/docs/components/footer/footer.tsx index 4009cc1de72..342f0b733ab 100644 --- a/apps/docs/components/footer/footer.tsx +++ b/apps/docs/components/footer/footer.tsx @@ -12,7 +12,7 @@ import { SIM_SITE_URL } from '@/lib/urls' */ const LINK_CLASS = - 'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]' + 'text-sm text-[var(--text-secondary)] transition-colors hover:text-[var(--text-primary)]' interface FooterItem { label: string @@ -23,7 +23,7 @@ interface FooterItem { const PRODUCT_LINKS: FooterItem[] = [ { label: 'Enterprise', href: `${SIM_SITE_URL}/enterprise`, external: true }, { label: 'Chat', href: '/chat' }, - { label: 'Workflows', href: '/introduction' }, + { label: 'Workflows', href: '/workflows' }, { label: 'Knowledge Base', href: '/knowledgebase' }, { label: 'Tables', href: '/tables' }, { label: 'MCP', href: '/agents/mcp' }, @@ -107,7 +107,7 @@ const LEGAL_LINKS: FooterItem[] = [ function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) { return (
-

{title}

+

{title}

{items.map(({ label, href, external }) => external ? ( @@ -169,7 +169,9 @@ export function Footer() { -

© 2026 Sim. All rights reserved.

+

+ © 2026 Sim. All rights reserved. +

) diff --git a/apps/docs/components/navbar/navbar.tsx b/apps/docs/components/navbar/navbar.tsx index fe62a1df658..f8b0997cc01 100644 --- a/apps/docs/components/navbar/navbar.tsx +++ b/apps/docs/components/navbar/navbar.tsx @@ -71,7 +71,7 @@ export function Navbar() { paddingRight: 'calc(var(--toc-offset) + var(--nav-inset))', }} > - + @@ -106,7 +106,7 @@ export function Navbar() { '-mb-px relative flex items-center border-b text-sm tracking-[-0.01em] transition-colors', isActive ? 'border-[var(--text-muted)] font-medium text-[var(--text-primary)]' - : 'border-transparent font-normal text-[var(--text-muted)] hover:border-[var(--border-1)] hover:text-[var(--text-secondary)]' + : 'border-transparent font-normal text-[var(--text-secondary)] hover:border-[var(--border-1)] hover:text-[var(--text-primary)]' )} > {/* Invisible bold text reserves width to prevent layout shift */} diff --git a/apps/docs/components/ui/block-info-card.tsx b/apps/docs/components/ui/block-info-card.tsx index 39f3c25fbd2..d20380c3d93 100644 --- a/apps/docs/components/ui/block-info-card.tsx +++ b/apps/docs/components/ui/block-info-card.tsx @@ -46,6 +46,7 @@ export function BlockInfoCard({ return (