mirror of
https://github.com/GitbookIO/gitbook.git
synced 2026-10-14 09:09:42 +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
|
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`
|
### `actions`
|
||||||
|
|
||||||
Available in: Standalone script, NPM package, React components
|
Available in: Standalone script, NPM package, React components
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run build-lib && bun run build-standalone",
|
"build": "bun run build-lib && bun run build-standalone",
|
||||||
"build-lib": "tsdown",
|
"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",
|
"clean": "rm -rf ./dist",
|
||||||
"unit": "bun test",
|
"unit": "bun test",
|
||||||
"typecheck": "tsc --noEmit",
|
"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.
|
* Display a close button inside the assistant.
|
||||||
*/
|
*/
|
||||||
closeButton?: boolean;
|
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 _client: GitBookClient | undefined;
|
||||||
let _frame: GitBookFrameClient | undefined;
|
let _frame: GitBookFrameClient | undefined;
|
||||||
let frameOptions: GetFrameURLOptions | undefined;
|
let frameOptions: GetFrameURLOptions | undefined;
|
||||||
|
let lastPushedColorScheme: 'light' | 'dark' | undefined;
|
||||||
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
|
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
|
||||||
button: {
|
button: {
|
||||||
label: 'Ask',
|
label: 'Ask',
|
||||||
@@ -85,6 +86,43 @@ widgetWindow.classList.add('hidden');
|
|||||||
document.body.appendChild(widgetButton);
|
document.body.appendChild(widgetButton);
|
||||||
document.body.appendChild(widgetWindow);
|
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() {
|
function getClient() {
|
||||||
if (!_client) {
|
if (!_client) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -99,6 +137,7 @@ function getIframe() {
|
|||||||
const client = getClient();
|
const client = getClient();
|
||||||
|
|
||||||
widgetIframe?.remove();
|
widgetIframe?.remove();
|
||||||
|
lastPushedColorScheme = undefined;
|
||||||
widgetIframe = document.createElement('iframe');
|
widgetIframe = document.createElement('iframe');
|
||||||
widgetIframe.id = 'gitbook-widget-iframe';
|
widgetIframe.id = 'gitbook-widget-iframe';
|
||||||
widgetIframe.src = client.getFrameURL({
|
widgetIframe.src = client.getFrameURL({
|
||||||
@@ -111,6 +150,8 @@ function getIframe() {
|
|||||||
widgetWindow.classList.add('hidden');
|
widgetWindow.classList.add('hidden');
|
||||||
widgetButton.classList.remove('open');
|
widgetButton.classList.remove('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pushColorSchemeToFrame();
|
||||||
}
|
}
|
||||||
return { iframe: widgetIframe, frame: _frame };
|
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,
|
...frameConfiguration,
|
||||||
});
|
});
|
||||||
|
pushColorSchemeToFrame();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'clearChat':
|
case 'clearChat':
|
||||||
|
|||||||
@@ -8,14 +8,6 @@
|
|||||||
|
|
||||||
--gitbook-widget-radius: .5rem;
|
--gitbook-widget-radius: .5rem;
|
||||||
--gitbook-widget-text-size: 1rem;
|
--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-icon-size: 1.25rem;
|
||||||
|
|
||||||
--gitbook-widget-window-width: 28rem; /* 448px */
|
--gitbook-widget-window-width: 28rem; /* 448px */
|
||||||
@@ -29,13 +21,20 @@
|
|||||||
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
--gitbook-widget-easing-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
#gitbook-widget-button,
|
||||||
:root {
|
#gitbook-widget-window {
|
||||||
--gitbook-widget-text-color: #FFFFFF;
|
--gitbook-widget-text-color: light-dark(#656973, #FFFFFF);
|
||||||
--gitbook-widget-border-color: #202020;
|
--gitbook-widget-border-color: light-dark(#e5e5e5, #202020);
|
||||||
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9);
|
--gitbook-widget-background-translucent: light-dark(rgba(255, 255, 255, 0.9), rgba(15, 15, 15, 0.9));
|
||||||
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 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: #f0f0f0;
|
--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 chat = useAIChatState();
|
||||||
const { config: siteConfig } = useAI();
|
const { config: siteConfig } = useAI();
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
const embedConfig = useEmbeddableConfiguration();
|
const embedConfig = useEmbeddableConfiguration((state) => state);
|
||||||
const language = useLanguage();
|
const language = useLanguage();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type EmbeddableAIContextProviderProps = PropsWithChildren<AIConfig>;
|
|||||||
*/
|
*/
|
||||||
export function EmbeddableAIContextProvider(props: EmbeddableAIContextProviderProps) {
|
export function EmbeddableAIContextProvider(props: EmbeddableAIContextProviderProps) {
|
||||||
const { aiMode, suggestions, greeting, trademark, children } = props;
|
const { aiMode, suggestions, greeting, trademark, children } = props;
|
||||||
const embedConfig = useEmbeddableConfiguration();
|
const embedConfig = useEmbeddableConfiguration((state) => state);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AIContextProvider
|
<AIContextProvider
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import React, { useEffect, useRef } from 'react';
|
|||||||
|
|
||||||
import { useAI, useAIChatController } from '@/components/AI';
|
import { useAI, useAIChatController } from '@/components/AI';
|
||||||
import { CustomizationAIMode } from '@gitbook/api';
|
import { CustomizationAIMode } from '@gitbook/api';
|
||||||
|
import { useTheme } from 'next-themes';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { createStore, useStore } from 'zustand';
|
import { createStore, useStore } from 'zustand';
|
||||||
import { integrationsAssistantTools } from '../Integrations';
|
import { integrationsAssistantTools } from '../Integrations';
|
||||||
@@ -36,11 +37,12 @@ export function EmbeddableIframeAPI(props: {
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const chatController = useAIChatController();
|
const chatController = useAIChatController();
|
||||||
|
const { setTheme } = useTheme();
|
||||||
|
|
||||||
// Live ref to avoid adding them as dependencies
|
// Live ref to avoid adding them as dependencies
|
||||||
const refs = useRef({ router, chatController, baseURL });
|
const refs = useRef({ router, chatController, baseURL, setTheme });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refs.current = { router, chatController, baseURL };
|
refs.current = { router, chatController, baseURL, setTheme };
|
||||||
});
|
});
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -57,7 +59,7 @@ export function EmbeddableIframeAPI(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
channel.receive((payload) => {
|
channel.receive((payload) => {
|
||||||
const { baseURL, router, chatController } = refs.current;
|
const { baseURL, router, chatController, setTheme } = refs.current;
|
||||||
const message = payload as ParentToFrameMessage;
|
const message = payload as ParentToFrameMessage;
|
||||||
|
|
||||||
log('[gitbook] received message', message);
|
log('[gitbook] received message', message);
|
||||||
@@ -78,6 +80,15 @@ export function EmbeddableIframeAPI(props: {
|
|||||||
integrationsAssistantTools.setState({
|
integrationsAssistantTools.setState({
|
||||||
tools: message.settings.tools,
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case 'navigateToPage': {
|
case 'navigateToPage': {
|
||||||
@@ -95,14 +106,11 @@ export function EmbeddableIframeAPI(props: {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Subscribe to embed configuration from the parent (Zustand). */
|
||||||
* Hook to get the configuration from the parent window.
|
export function useEmbeddableConfiguration<T>(
|
||||||
*/
|
selector: (state: GitBookEmbeddableConfiguration) => T
|
||||||
export function useEmbeddableConfiguration<T = GitBookEmbeddableConfiguration>(
|
): T {
|
||||||
// @ts-expect-error - This is a workaround to allow the function to be optional.
|
return useStore(embeddableConfiguration, selector);
|
||||||
fn: (state: GitBookEmbeddableConfiguration) => T = (state) => state
|
|
||||||
) {
|
|
||||||
return useStore(embeddableConfiguration, fn);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,7 +159,7 @@ export function EmbeddableIframeTabs(props: {
|
|||||||
siteTitle: string;
|
siteTitle: string;
|
||||||
}) {
|
}) {
|
||||||
const { ref, active = 'assistant', baseURL, siteTitle } = props;
|
const { ref, active = 'assistant', baseURL, siteTitle } = props;
|
||||||
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration();
|
const { tabs: configuredTabs, actions } = useEmbeddableConfiguration((state) => state);
|
||||||
|
|
||||||
const { assistants, config } = useAI();
|
const { assistants, config } = useAI();
|
||||||
|
|
||||||
@@ -223,7 +231,7 @@ export function EmbeddableIframeTabs(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function EmbeddableIframeCloseButton() {
|
export function EmbeddableIframeCloseButton() {
|
||||||
const { closeButton } = useEmbeddableConfiguration();
|
const { closeButton } = useEmbeddableConfiguration((state) => state);
|
||||||
|
|
||||||
if (!closeButton) {
|
if (!closeButton) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user