Compare commits

...

5 Commits

Author SHA1 Message Date
Zeno Kapitein 210cdcaa68 Fix 2026-04-09 09:52:05 +02:00
Zeno Kapitein d4247d25f1 Typecheck 2026-04-08 18:47:56 +02:00
Zeno Kapitein 33e87a0183 Simplify 2026-04-08 18:28:46 +02:00
Zeno Kapitein 75a217d5c9 New implementation 2026-04-08 18:15:38 +02:00
Zeno Kapitein fed63b4675 Docs Embed: Better support light/dark mode overrides 2026-04-08 14:23:38 +02:00
9 changed files with 162 additions and 32 deletions
+38
View File
@@ -285,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';
};
/**
+45 -1
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,43 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow);
/** Resolved `color-scheme` from the iframe element (incl. inheritance from `#gitbook-widget-window`). */
function colorSchemeFromIframe(): 'light' | 'dark' | undefined {
if (!widgetIframe) return undefined;
const v = getComputedStyle(widgetIframe).colorScheme.trim().toLowerCase();
return v === 'dark' || v === 'light' ? v : undefined;
}
function pushColorSchemeToFrame() {
if (!_frame) return;
const desired = frameConfiguration.colorScheme ?? colorSchemeFromIframe();
if (desired === lastPushedColorScheme) return;
lastPushedColorScheme = desired;
_frame.configure({
...frameConfiguration,
colorScheme: desired,
});
}
/** Re-push when the host page or widget chrome changes theme (class/style) or OS preference changes. */
function installHostThemeBridge() {
const onChange = () => pushColorSchemeToFrame();
window.matchMedia?.('(prefers-color-scheme: dark)')?.addEventListener?.('change', onChange);
if (typeof MutationObserver === 'undefined') return;
const observer = new MutationObserver(onChange);
const opts: MutationObserverInit = {
attributes: true,
attributeFilter: ['class', 'style'],
};
observer.observe(document.documentElement, opts);
// Safari: `color-scheme` on `#gitbook-widget-window` does not always surface on `<html>`.
observer.observe(widgetWindow, opts);
}
installHostThemeBridge();
function getClient() {
if (!_client) {
throw new Error(
@@ -99,6 +137,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 +150,8 @@ function getIframe() {
widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open');
});
pushColorSchemeToFrame();
}
return { iframe: widgetIframe, frame: _frame };
}
@@ -188,9 +229,12 @@ const GitBook = (...args: StandaloneCalls) => {
}
}
getIframe().frame.configure({
const { frame } = getIframe();
// Always propagate configuration updates, even when color-scheme doesn't change.
frame.configure({
...frameConfiguration,
});
pushColorSchemeToFrame();
break;
}
case 'clearChat':
+14 -15
View File
@@ -8,14 +8,6 @@
--gitbook-widget-radius: .5rem;
--gitbook-widget-text-size: 1rem;
--gitbook-widget-text-color: #656973;
--gitbook-widget-border-color: #e5e5e5;
--gitbook-widget-background-translucent: rgba(255, 255, 255, 0.9);
--gitbook-widget-background-translucent-hover: rgba(250, 250, 250, 0.9);
--gitbook-widget-background-solid: #FFFFFF;
--gitbook-widget-background-solid-hover: #FBFBFB;
--gitbook-widget-icon-size: 1.25rem;
--gitbook-widget-window-width: 28rem; /* 448px */
@@ -29,13 +21,20 @@
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
}
@media (prefers-color-scheme: dark) {
:root {
--gitbook-widget-text-color: #FFFFFF;
--gitbook-widget-border-color: #202020;
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9);
--gitbook-widget-background-solid: #f0f0f0;
#gitbook-widget-button,
#gitbook-widget-window {
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
--gitbook-widget-background-translucent-hover: light-dark(rgba(250, 250, 250, 0.9), rgba(20, 20, 20, 0.9));
--gitbook-widget-background-solid: light-dark(#FFFFFF, #f0f0f0);
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #f0f0f0);
&[data-color-scheme=light] {
color-scheme: light;
}
&[data-color-scheme=dark] {
color-scheme: dark;
}
}
@@ -43,7 +43,7 @@ export function EmbeddableAIChat(props: EmbeddableAIChatProps) {
const chat = useAIChatState();
const { config: siteConfig } = useAI();
const chatController = useAIChatController();
const embedConfig = useEmbeddableConfiguration();
const embedConfig = useEmbeddableConfiguration((state) => state);
const language = useLanguage();
React.useEffect(() => {
@@ -12,7 +12,7 @@ type EmbeddableAIContextProviderProps = PropsWithChildren<AIConfig>;
*/
export function EmbeddableAIContextProvider(props: EmbeddableAIContextProviderProps) {
const { aiMode, suggestions, greeting, trademark, children } = props;
const embedConfig = useEmbeddableConfiguration();
const embedConfig = useEmbeddableConfiguration((state) => state);
return (
<AIContextProvider
@@ -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': {
@@ -95,14 +106,11 @@ export function EmbeddableIframeAPI(props: {
return null;
}
/**
* 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);
/** Subscribe to embed configuration from the parent (Zustand). */
export function useEmbeddableConfiguration<T>(
selector: (state: GitBookEmbeddableConfiguration) => T
): T {
return useStore(embeddableConfiguration, selector);
}
/**
@@ -151,7 +159,7 @@ export function EmbeddableIframeTabs(props: {
siteTitle: string;
}) {
const { ref, active = 'assistant', baseURL, siteTitle } = props;
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration();
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration((state) => state);
const { assistants, config } = useAI();
@@ -223,7 +231,7 @@ export function EmbeddableIframeTabs(props: {
}
export function EmbeddableIframeCloseButton() {
const { closeButton } = useEmbeddableConfiguration();
const { closeButton } = useEmbeddableConfiguration((state) => state);
if (!closeButton) {
return null;