mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-09-16 07:35:16 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 210cdcaa68 | |||
| d4247d25f1 | |||
| 33e87a0183 | |||
| 75a217d5c9 | |||
| fed63b4675 |
@@ -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 site’s 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
|
||||
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user