+
+ );
+}
+
+function SiteWordmark() {
+ const linkProps = useLinkProps({
+ activeProps: {},
+ className: 'flex h-14 shrink-0 items-center truncate font-semibold text-sm',
+ params: { _splat: '' },
+ to: '/$',
+ });
+
+ return (
+
+ Luke UI
+
+ );
+}
+
+function AppearancePopover() {
+ return (
+
+
+ Theme
+
+
+
+
+
+ );
+}
diff --git a/apps/docs/src/lib/layout.shared.tsx b/apps/docs/src/lib/layout.shared.tsx
index b1bd75c8..20d90d6e 100644
--- a/apps/docs/src/lib/layout.shared.tsx
+++ b/apps/docs/src/lib/layout.shared.tsx
@@ -1,25 +1,20 @@
-import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
-import { ThemeControls } from '../components/theme-controls';
-import { getStorybookBaseUrl } from './storybook';
+import type { DocsLayoutProps } from 'fumadocs-ui/layouts/notebook';
+import { DocsSiteNav } from '../components/docs-site-nav.js';
+import { siteDestinations } from './site-destinations.js';
-export function baseOptions(): BaseLayoutProps {
+export function baseOptions(): Omit {
return {
- links: [
- {
- text: 'Playground',
- url: '/playground',
- },
- {
- external: true,
- text: 'Storybook',
- url: `${getStorybookBaseUrl(import.meta.env.BASE_URL)}/`,
- },
- ],
+ links: siteDestinations.map((destination) => ({
+ external: destination.isExternal ?? false,
+ text: destination.label,
+ url: destination.url,
+ })),
nav: {
- title: 'Luke UI',
+ mode: 'top',
},
slots: {
- themeSwitch: ThemeControls,
+ header: DocsSiteNav,
+ themeSwitch: false,
},
};
}
diff --git a/apps/docs/src/lib/site-destinations.test.ts b/apps/docs/src/lib/site-destinations.test.ts
new file mode 100644
index 00000000..f24f76e1
--- /dev/null
+++ b/apps/docs/src/lib/site-destinations.test.ts
@@ -0,0 +1,17 @@
+import { expect, test } from 'vite-plus/test';
+import { getActiveSiteDestination } from './site-destinations.js';
+
+test('the docs destination covers every route the playground does not', () => {
+ expect(getActiveSiteDestination('/')?.label).toBe('Docs');
+ expect(getActiveSiteDestination('/components/actions/button')?.label).toBe('Docs');
+ expect(getActiveSiteDestination('/theming')?.label).toBe('Docs');
+});
+
+test('the playground destination wins over the docs root it sits under', () => {
+ expect(getActiveSiteDestination('/playground')?.label).toBe('Playground');
+ expect(getActiveSiteDestination('/playground/preview')?.label).toBe('Playground');
+});
+
+test('a route that only shares a name prefix with a destination is not active', () => {
+ expect(getActiveSiteDestination('/playgrounds')?.label).toBe('Docs');
+});
diff --git a/apps/docs/src/lib/site-destinations.ts b/apps/docs/src/lib/site-destinations.ts
new file mode 100644
index 00000000..91d472c2
--- /dev/null
+++ b/apps/docs/src/lib/site-destinations.ts
@@ -0,0 +1,37 @@
+import { getStorybookBaseUrl } from './storybook.js';
+
+export interface SiteDestination {
+ activePath?: string;
+ isExternal?: boolean;
+ label: string;
+ url: string;
+}
+
+export const siteDestinations: ReadonlyArray = [
+ { activePath: '/', label: 'Docs', url: '/' },
+ { activePath: '/playground', label: 'Playground', url: '/playground' },
+ {
+ isExternal: true,
+ label: 'Storybook',
+ url: `${getStorybookBaseUrl(import.meta.env.BASE_URL)}/`,
+ },
+];
+
+export function getActiveSiteDestination(pathname: string): SiteDestination | undefined {
+ let match: SiteDestination | undefined;
+
+ for (const destination of siteDestinations) {
+ const { activePath } = destination;
+ if (activePath === undefined) continue;
+ if (!isAtOrBelow(pathname, activePath)) continue;
+ if (match?.activePath !== undefined && match.activePath.length >= activePath.length) continue;
+ match = destination;
+ }
+
+ return match;
+}
+
+function isAtOrBelow(pathname: string, activePath: string): boolean {
+ if (activePath === '/') return true;
+ return pathname === activePath || pathname.startsWith(`${activePath}/`);
+}
diff --git a/apps/docs/src/routes/$.tsx b/apps/docs/src/routes/$.tsx
index 3e6490f9..786b785c 100644
--- a/apps/docs/src/routes/$.tsx
+++ b/apps/docs/src/routes/$.tsx
@@ -4,8 +4,8 @@ import { staticFunctionMiddleware } from '@tanstack/start-static-server-function
import { useFumadocsLoader } from 'fumadocs-core/source/client';
import { AutoTypeTable } from 'fumadocs-typescript/ui';
import { TypeTable } from 'fumadocs-ui/components/type-table';
-import { DocsLayout } from 'fumadocs-ui/layouts/docs';
-import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
+import { DocsLayout } from 'fumadocs-ui/layouts/notebook';
+import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/notebook/page';
import defaultMdxComponents from 'fumadocs-ui/mdx';
import { Suspense } from 'react';
import * as z from 'zod';
diff --git a/apps/docs/src/routes/playground/index.tsx b/apps/docs/src/routes/playground/index.tsx
index 4f48b402..eec26c89 100644
--- a/apps/docs/src/routes/playground/index.tsx
+++ b/apps/docs/src/routes/playground/index.tsx
@@ -1,5 +1,5 @@
import { cx } from '@luke-ui/react/utils';
-import { ClientOnly, createFileRoute, Link } from '@tanstack/react-router';
+import { ClientOnly, createFileRoute } from '@tanstack/react-router';
import { lazy, Suspense, useCallback, useEffect, useReducer, useRef, useState } from 'react';
import { Group, Panel, Separator } from 'react-resizable-panels';
import { useSpinDoctor } from 'spin-doctor';
@@ -12,7 +12,8 @@ import {
import { PreviewToolbar } from '../../components/playground/preview-toolbar';
import { useIsDesktop } from '../../components/playground/use-is-desktop';
import type { ViewportWidth } from '../../components/playground/viewport-toggle';
-import { ThemeControls, useDocsThemeIdentity } from '../../components/theme-controls';
+import { SiteNav } from '../../components/site-nav.js';
+import { useDocsThemeIdentity } from '../../components/theme-controls';
import rawDefaultCode from '../../lib/playground-default-code.tsx?raw';
import { decodeCodeHash, encodeCodeHash } from '../../lib/playground-hash';
import type {
@@ -137,21 +138,7 @@ function Playground() {
return (
-
-
- Luke UI Playground
-
- Docs
-
-
-
-
-
-
+ {
include: [
'@monaco-editor/react',
'@react-aria/utils',
+ '@tanstack/react-router',
'@vanilla-extract/recipes',
'@vanilla-extract/recipes/createRuntimeFn',
+ // Fumadocs' chrome (search trigger, popover, sidebar) imports icons
+ // from lucide-react's barrel, which unbundled is ~1750 separate module
+ // requests. On the playground, which also loads Monaco, that exhausts
+ // the browser's connection pool and the page never hydrates.
+ 'fumadocs-ui > lucide-react',
'lz-string',
'monaco-editor',
'react-aria-components/Breadcrumbs',
diff --git a/docs/DOCUMENTATION.md b/docs/DOCUMENTATION.md
index b4affc4f..71c16bb5 100644
--- a/docs/DOCUMENTATION.md
+++ b/docs/DOCUMENTATION.md
@@ -57,6 +57,26 @@ update an example in the same change when the feature is easier to understand vi
Use short, legible sample values. Do not use lorem ipsum.
+## Site chrome
+
+Every surface shares one top nav, `SiteNav` in `apps/docs/src/components/site-nav.tsx`. It carries
+the wordmark, the primary destinations, search, and the appearance controls. The destination list
+and its active-route matching live in `apps/docs/src/lib/site-destinations.ts`, so the nav and the
+docs layout navigate to the same places. Appearance controls belong to the nav on every surface, not
+to the docs sidebar footer.
+
+The docs routes use Fumadocs' notebook layout with `nav.mode: 'top'`, which spans the header across
+the full width and starts the sidebar beneath it. `apps/docs/src/lib/layout.shared.tsx` supplies the
+nav through the layout's `header` slot as `DocsSiteNav`
+(`apps/docs/src/components/docs-site-nav.tsx`), which adds the sidebar triggers. The playground and
+the 404 render `SiteNav` directly.
+
+`DocsSiteNav` passes `hasSidebarNavigation`, which hides the bar's destinations below `lg` — the
+breakpoint where Fumadocs starts listing them in the sidebar and its mobile drawer instead, so they
+never appear twice. It also keeps the bar on one row at exactly `h-14`, which the layout's
+`--fd-header-height` is declared to match; changing the bar's height means changing both. Surfaces
+with no sidebar keep the destinations at every width, moving them to a second nav row below `md`.
+
## Playground
The docs site has a live playground at `/playground`: a Monaco editor with TypeScript IntelliSense