New implementation

This commit is contained in:
Zeno Kapitein
2026-04-08 18:15:38 +02:00
parent fed63b4675
commit 75a217d5c9
7 changed files with 146 additions and 15 deletions
+38 -10
View File
@@ -32,16 +32,6 @@ window.GitBook('show');
The standalone script provides a global `GitBook` function. See the [API Reference](#api-reference) section for all available methods.
### Color mode behavior and override
- If the site's Customization settings enforce a single mode (`light` or `dark`), the embed always uses that mode.
- If the site supports both light and dark, the embed uses the page's `color-scheme` when defined or the browser/OS preference as a fallback.
- You can also force the standalone widget to a specific mode (if the site supports it) by setting `data-color-scheme` on the button and/or window elements.
Supported values:
- `light`
- `dark`
### Example: Configuring the widget
```javascript
@@ -295,6 +285,44 @@ Display GitBook branding in the embed. Defaults to true.
trademark: true
```
### Theming and `color-scheme` (CSS-first)
The embed supports both site-controlled theming and CSS-driven theming.
Precedence (highest → lowest):
- **Site mode / forced theme**: if the GitBook site does not support multiple themes, the embed is forced to the sites default theme.
- **Visitor preference**: if the site supports multiple themes and the visitor has previously selected a theme on the site, that preference is remembered.
- **Browser/OS default**: otherwise the embed follows the browser/OS preference (`prefers-color-scheme`).
You can also drive the embed theme from CSS by setting `color-scheme` on the iframe element (or a parent it inherits from). When the iframe resolves to an explicit `color-scheme: light` or `color-scheme: dark`, that value is propagated into the embedded content so it renders consistently.
Standalone widget example:
```css
/* Force the GitBook widget iframe to render in dark mode */
#gitbook-widget-iframe {
color-scheme: dark;
}
```
### `colorScheme`
Available in: Standalone script, NPM package, React components
Force the embed to render in a specific color scheme.
- **Type**: `'light' | 'dark'`
- **Default**: `undefined` (follow site/visitor preference/system)
```javascript
GitBook('configure', {
colorScheme: 'dark'
});
```
To clear an override, omit `colorScheme` (or set it to `undefined` in JS) in a subsequent `configure` call.
### `actions`
Available in: Standalone script, NPM package, React components
+1 -1
View File
@@ -32,7 +32,7 @@
"scripts": {
"build": "bun run build-lib && bun run build-standalone",
"build-lib": "tsdown",
"build-standalone": "bun build src/standalone/index.ts --bundle --minify --outdir=standalone",
"build-standalone": "bun run ./scripts/build-standalone.ts",
"clean": "rm -rf ./dist",
"unit": "bun test",
"typecheck": "tsc --noEmit",
@@ -0,0 +1,36 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { Features, transform } from 'lightningcss';
/**
* Build the standalone embed script.
* Bun's default CSS transpiler (which is a port of LightningCSS) strips out the native light-dark() function in favour of a polyfill.
* Light-dark() is widely supported now, and the polyfill requires you to set a data attribute on the element instead of relying on plain CSS.
* This script's purpose is to pass a feature flag to the CSS transpiler to keep the native light-dark() behavior.
*/
await new Promise<void>((resolve, reject) => {
const child = spawn(
'bun',
['build', 'src/standalone/index.ts', '--bundle', '--minify', '--outdir=standalone'],
{ stdio: 'inherit' }
);
child.on('error', reject);
child.on('exit', (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`bun build failed with exit code ${code ?? 'unknown'}`));
});
});
const sourceCSS = await readFile('src/standalone/style.css');
const transformedCSS = transform({
filename: 'src/standalone/style.css',
code: sourceCSS,
minify: true,
// Keep native light-dark() behavior scoped to element color-scheme.
exclude: Features.LightDark,
});
await writeFile('standalone/index.css', transformedCSS.code);
+5
View File
@@ -83,6 +83,11 @@ export type GitBookEmbeddableConfiguration = {
* Display a close button inside the assistant.
*/
closeButton?: boolean;
/**
* Force the embed to render in a specific color-scheme.
*/
colorScheme?: 'light' | 'dark';
};
/**
+51
View File
@@ -55,6 +55,7 @@ let widgetIframe: HTMLIFrameElement | undefined;
let _client: GitBookClient | undefined;
let _frame: GitBookFrameClient | undefined;
let frameOptions: GetFrameURLOptions | undefined;
let lastPushedColorScheme: 'light' | 'dark' | undefined;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: {
label: 'Ask',
@@ -85,6 +86,52 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
function pushColorSchemeToFrame() {
if (!_frame) return;
// Manual override via configure always wins.
const desired =
frameConfiguration.colorScheme ??
(widgetIframe
? ((() => {
const declared = getComputedStyle(widgetIframe).colorScheme.trim().toLowerCase();
if (declared === 'dark') return 'dark';
if (declared === 'light') return 'light';
// If the iframe element doesn't have an explicit scheme, don't force anything.
// The embedded content can rely on `forcedTheme` or system theme.
return undefined;
})() as 'light' | 'dark' | undefined)
: undefined);
if (desired === lastPushedColorScheme) {
return;
}
lastPushedColorScheme = desired;
_frame.configure({
...frameConfiguration,
colorScheme: desired,
});
}
// Watch for host theme changes and push them down into the iframe.
// This does not mutate the host/widget styling; it only re-configures the frame content.
if (typeof MutationObserver !== 'undefined') {
const observer = new MutationObserver(() => pushColorSchemeToFrame());
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style'],
});
// Safari needs an explicit watch on the widget container when `color-scheme`
// is overridden there (e.g. `#gitbook-widget-window { color-scheme: dark; }`).
observer.observe(widgetWindow, { attributes: true, attributeFilter: ['class', 'style'] });
}
{
const mql = window.matchMedia?.('(prefers-color-scheme: dark)');
const handler = () => pushColorSchemeToFrame();
mql?.addEventListener?.('change', handler);
}
function getClient() {
if (!_client) {
throw new Error(
@@ -99,6 +146,7 @@ function getIframe() {
const client = getClient();
widgetIframe?.remove();
lastPushedColorScheme = undefined;
widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe';
widgetIframe.src = client.getFrameURL({
@@ -111,6 +159,8 @@ function getIframe() {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
pushColorSchemeToFrame();
}
return { iframe: widgetIframe, frame: _frame };
}
@@ -191,6 +241,7 @@ const GitBook = (...args: StandaloneCalls) => {
getIframe().frame.configure({
...frameConfiguration,
});
pushColorSchemeToFrame();
break;
}
case 'clearChat':
+1
View File
@@ -37,6 +37,7 @@
color-scheme: dark;
}
}
* {
box-sizing: border-box;
}
@@ -5,6 +5,7 @@ import React, { useEffect, useRef } from 'react';
import { useAI, useAIChatController } from '@/components/AI';
import { CustomizationAIMode } from '@gitbook/api';
import { useTheme } from 'next-themes';
import { useRouter } from 'next/navigation';
import { createStore, useStore } from 'zustand';
import { integrationsAssistantTools } from '../Integrations';
@@ -36,11 +37,12 @@ export function EmbeddableIframeAPI(props: {
const router = useRouter();
const chatController = useAIChatController();
const { setTheme } = useTheme();
// Live ref to avoid adding them as dependencies
const refs = useRef({ router, chatController, baseURL });
const refs = useRef({ router, chatController, baseURL, setTheme });
useEffect(() => {
refs.current = { router, chatController, baseURL };
refs.current = { router, chatController, baseURL, setTheme };
});
React.useEffect(() => {
@@ -57,7 +59,7 @@ export function EmbeddableIframeAPI(props: {
}
channel.receive((payload) => {
const { baseURL, router, chatController } = refs.current;
const { baseURL, router, chatController, setTheme } = refs.current;
const message = payload as ParentToFrameMessage;
log('[gitbook] received message', message);
@@ -78,6 +80,15 @@ export function EmbeddableIframeAPI(props: {
integrationsAssistantTools.setState({
tools: message.settings.tools,
});
if (Object.prototype.hasOwnProperty.call(message.settings, 'colorScheme')) {
const colorScheme = message.settings.colorScheme;
if (!colorScheme) {
setTheme('system');
} else {
setTheme(colorScheme);
}
}
break;
}
case 'navigateToPage': {
@@ -99,7 +110,6 @@ export function EmbeddableIframeAPI(props: {
* Hook to get the configuration from the parent window.
*/
export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
// @ts-expect-error - This is a workaround to allow the function to be optional.
fn: (state: GitBookEmbeddableConfiguration) => T = (state) => state
) {
return useStore(embeddableConfiguration, fn);