Docs Embed: Better support light/dark mode overrides (#4181)

This commit is contained in:
Zeno Kapitein
2026-04-14 10:50:05 +02:00
committed by GitHub
parent 68c842bd4c
commit 8d2a95b168
11 changed files with 180 additions and 14 deletions
+6
View File
@@ -0,0 +1,6 @@
---
"gitbook": patch
"@gitbook/embed": patch
---
Docs Embed: Better support light/dark mode overrides
+23 -3
View File
@@ -72,6 +72,7 @@ const gitbook = createGitBook({
// Create an iframe and get its URL
const iframe = document.createElement('iframe');
iframe.src = gitbook.getFrameURL({
colorScheme: 'dark', // Optional: force the embed to render in dark mode
visitor: {
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { // Optional: custom claims for dynamic expressions
@@ -122,6 +123,7 @@ import { GitBookProvider, GitBookFrame } from '@gitbook/embed/react';
<GitBookProvider siteURL="https://docs.company.com">
<GitBookFrame
colorScheme="dark"
visitor={{
token: 'your-jwt-token', // Optional: for Adaptive Content or Authenticated Access
unsignedClaims: { userId: '123' } // Optional: custom claims for dynamic expressions
@@ -150,7 +152,7 @@ import { useGitBook } from '@gitbook/embed/react';
function MyComponent() {
const gitbook = useGitBook();
const frameURL = gitbook.getFrameURL({ visitor: { token: '...' } });
const frameURL = gitbook.getFrameURL({ colorScheme: 'dark', visitor: { token: '...' } });
// ...
}
```
@@ -178,7 +180,7 @@ function MyComponent() {
### Standalone Script
- `GitBook('init', options: { siteURL: string }, frameOptions?: { visitor?: {...} })` - Initialize widget
- `GitBook('init', options: { siteURL: string }, frameOptions?: { colorScheme?: 'light' | 'dark', visitor?: {...} })` - Initialize widget
- `GitBook('show')` - Show widget button
- `GitBook('hide')` - Hide widget button
- `GitBook('open')` - Open widget window
@@ -195,7 +197,7 @@ function MyComponent() {
**Client Factory:**
- `createGitBook(options: { siteURL: string })``GitBookClient`
- `client.getFrameURL(options?: { visitor?: {...} })``string`
- `client.getFrameURL(options?: { colorScheme?: 'light' | 'dark', visitor?: {...} })``string`
- `client.createFrame(iframe: HTMLIFrameElement)``GitBookFrameClient`
**Frame Client:**
@@ -457,6 +459,24 @@ visitor: {
}
```
### `colorScheme`
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference.
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)`
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'`
```javascript
colorScheme: 'dark'
```
### `button`
Available in: Standalone script only
@@ -35,4 +35,17 @@ describe('createGitBook.getFrameURL', () => {
expect(url.searchParams.get('visitor.count')).toBe('3');
expect(url.searchParams.get('visitor.enabled')).toBe('false');
});
it('adds an explicit color scheme override when requested', () => {
const client = createGitBook({ siteURL: 'https://example.com/docs/' });
const url = new URL(
client.getFrameURL({
colorScheme: 'dark',
})
);
expect(url.pathname).toBe('/docs/~gitbook/embed');
expect(url.searchParams.get('theme')).toBe('dark');
});
});
@@ -8,6 +8,12 @@ export type CreateGitBookOptions = {
};
export type GetFrameURLOptions = {
/**
* Override the color scheme used by the embedded docs.
* When omitted, the embed follows the iframe's CSS `color-scheme`.
*/
colorScheme?: 'light' | 'dark';
/**
* Authentication to use for the frame.
*/
@@ -42,6 +48,10 @@ export function createGitBook(options: CreateGitBookOptions) {
const url = new URL(options.siteURL);
url.pathname = `${url.pathname.endsWith('/') ? url.pathname : `${url.pathname}/`}~gitbook/embed`;
if (frameOptions.colorScheme) {
url.searchParams.set('theme', frameOptions.colorScheme);
}
if (frameOptions.visitor?.token) {
url.searchParams.set('jwt_token', frameOptions.visitor.token);
}
+6 -1
View File
@@ -19,6 +19,7 @@ export type GitBookFrameProps = {
export function GitBookFrame(props: GitBookFrameProps) {
const {
className,
colorScheme,
visitor,
actions = [],
greeting,
@@ -34,7 +35,10 @@ export function GitBookFrame(props: GitBookFrameProps) {
const gitbook = useGitBook();
const [gitbookFrame, setGitbookFrame] = useState<GitBookFrameClient | null>(null);
const frameURL = useMemo(() => gitbook.getFrameURL({ visitor }), [gitbook, visitor]);
const frameURL = useMemo(
() => gitbook.getFrameURL({ visitor, colorScheme }),
[gitbook, visitor, colorScheme]
);
useEffect(() => {
if (frameRef.current) {
@@ -73,6 +77,7 @@ export function GitBookFrame(props: GitBookFrameProps) {
width="100%"
height="100%"
className={className}
style={colorScheme ? { colorScheme } : undefined}
/>
);
}
+3
View File
@@ -101,6 +101,9 @@ function getIframe() {
widgetIframe?.remove();
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
if (frameOptions?.colorScheme) {
widgetIframe.style.colorScheme = frameOptions.colorScheme;
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
@@ -5,6 +5,7 @@ import {
generateEmbeddableViewport,
} from '@/components/Embeddable';
import { getEmbeddableStaticContext } from '@/lib/embeddable';
import { getThemeFromMiddleware } from '@/lib/middleware';
import { shouldTrackEvents } from '@/lib/tracking';
import { headers } from 'next/headers';
@@ -18,12 +19,14 @@ export default async function RootLayout({
}: React.PropsWithChildren<SiteStaticLayoutProps>) {
const { context, visitorAuthClaims } = await getEmbeddableStaticContext(await params);
const withTracking = shouldTrackEvents(await headers());
const forcedTheme = await getThemeFromMiddleware();
return (
<EmbeddableRootLayout
context={context}
withTracking={withTracking}
visitorAuthClaims={visitorAuthClaims}
forcedTheme={forcedTheme}
>
{children}
</EmbeddableRootLayout>
@@ -6,6 +6,8 @@ import {
} from '@/components/SiteLayout';
import type { VisitorAuthClaims } from '@/lib/adaptive';
import type { GitBookSiteContext } from '@/lib/context';
import { resolveEmbeddableTheme } from '@/lib/embeddable';
import type { CustomizationDefaultThemeMode } from '@gitbook/api';
import { SiteInsightsTrademarkPlacement } from '@gitbook/api';
import { SpaceLayoutServerContext } from '../SpaceLayout';
import { Trademark } from '../TableOfContents/Trademark';
@@ -18,6 +20,7 @@ type EmbeddableRootLayoutProps = {
context: GitBookSiteContext;
withTracking: boolean;
visitorAuthClaims: VisitorAuthClaims;
forcedTheme?: CustomizationDefaultThemeMode | null;
};
/**
@@ -27,17 +30,22 @@ export async function EmbeddableRootLayout({
context,
withTracking,
visitorAuthClaims,
forcedTheme,
children,
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
const theme = resolveEmbeddableTheme(context.customization, forcedTheme);
return (
<CustomizationRootLayout context={context} htmlClassName="embed">
<CustomizationRootLayout
context={context}
htmlClassName="embed"
forcedTheme={theme.htmlTheme}
>
<SiteLayoutClientContexts
forcedTheme={
context.customization.themes.toggeable
? undefined
: context.customization.themes.default
}
defaultTheme={context.customization.themes.default}
forcedTheme={theme.forcedTheme}
defaultTheme={theme.defaultTheme}
// Keep embed theme separate from site so it does not reuse the full site's saved theme and vice versa.
themeStorageKey={`gitbook-theme-embed:${context.site.id}`}
externalLinksTarget={context.customization.externalLinks.target}
contextId={context.contextId}
proxyOrigin={context.site.proxy?.origin}
@@ -17,13 +17,21 @@ import { isExternalLink } from '../utils/link';
export function SiteLayoutClientContexts(props: {
forcedTheme: CustomizationDefaultThemeMode | undefined;
defaultTheme: CustomizationDefaultThemeMode | undefined;
themeStorageKey?: string;
externalLinksTarget: SiteExternalLinksTarget;
contextId: string | undefined;
proxyOrigin: string | undefined;
children: React.ReactNode;
}) {
const { children, forcedTheme, defaultTheme, externalLinksTarget, contextId, proxyOrigin } =
props;
const {
children,
forcedTheme,
defaultTheme,
themeStorageKey,
externalLinksTarget,
contextId,
proxyOrigin,
} = props;
useClearRouterCache(contextId);
@@ -51,6 +59,7 @@ export function SiteLayoutClientContexts(props: {
enableSystem
forcedTheme={forcedTheme}
defaultTheme={defaultTheme}
storageKey={themeStorageKey}
>
<NuqsAdapter>
<LinkContext.Provider value={linkContext}>
+57 -1
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import { getEmbeddableLinker } from './embeddable';
import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api';
import { getEmbeddableLinker, resolveEmbeddableTheme } from './embeddable';
import { createLinker } from './links';
describe('getEmbeddableLinker', () => {
@@ -21,3 +22,58 @@ describe('getEmbeddableLinker', () => {
);
});
});
describe('resolveEmbeddableTheme', () => {
function createCustomization(
themes: SiteCustomizationSettings['themes']
): Pick<SiteCustomizationSettings, 'themes'> {
return { themes };
}
it('follows the frame color scheme for multi-theme sites by default', () => {
expect(
resolveEmbeddableTheme(
createCustomization({
toggeable: true,
default: CustomizationDefaultThemeMode.Dark,
})
)
).toEqual({
htmlTheme: CustomizationDefaultThemeMode.System,
defaultTheme: CustomizationDefaultThemeMode.System,
forcedTheme: undefined,
});
});
it('accepts an explicit override for multi-theme sites', () => {
expect(
resolveEmbeddableTheme(
createCustomization({
toggeable: true,
default: CustomizationDefaultThemeMode.Light,
}),
CustomizationDefaultThemeMode.Dark
)
).toEqual({
htmlTheme: CustomizationDefaultThemeMode.Dark,
defaultTheme: CustomizationDefaultThemeMode.Dark,
forcedTheme: CustomizationDefaultThemeMode.Dark,
});
});
it('keeps the site theme for single-theme sites', () => {
expect(
resolveEmbeddableTheme(
createCustomization({
toggeable: false,
default: CustomizationDefaultThemeMode.Light,
}),
CustomizationDefaultThemeMode.Dark
)
).toEqual({
htmlTheme: CustomizationDefaultThemeMode.Light,
defaultTheme: CustomizationDefaultThemeMode.Light,
forcedTheme: CustomizationDefaultThemeMode.Light,
});
});
});
+33
View File
@@ -3,6 +3,7 @@ import type { GitBookSiteContext } from '@/lib/context';
import type { GitBookLinker } from '@/lib/links';
import { getPagePath } from '@/lib/pages';
import { joinPath } from '@/lib/paths';
import { CustomizationDefaultThemeMode, type SiteCustomizationSettings } from '@gitbook/api';
/**
* Get the context for the embeddable static routes.
@@ -68,3 +69,35 @@ export function getEmbeddableLinker(linker: GitBookLinker): GitBookLinker {
},
};
}
/**
* Resolve theme behavior for docs embeds.
* Embeds should follow the parent frame's color-scheme by default,
* while still allowing an explicit override for multi-theme sites.
*/
export function resolveEmbeddableTheme(
customization: Pick<SiteCustomizationSettings, 'themes'>,
forcedTheme?: CustomizationDefaultThemeMode | null
) {
if (!customization.themes.toggeable) {
return {
htmlTheme: customization.themes.default,
defaultTheme: customization.themes.default,
forcedTheme: customization.themes.default,
};
}
if (forcedTheme) {
return {
htmlTheme: forcedTheme,
defaultTheme: forcedTheme,
forcedTheme,
};
}
return {
htmlTheme: CustomizationDefaultThemeMode.System,
defaultTheme: CustomizationDefaultThemeMode.System,
forcedTheme: undefined,
};
}