diff --git a/packages/embed/README.md b/packages/embed/README.md index 18008dcd7..13cc39430 100644 --- a/packages/embed/README.md +++ b/packages/embed/README.md @@ -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 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 diff --git a/packages/embed/package.json b/packages/embed/package.json index 6615e81d0..9b38ea702 100644 --- a/packages/embed/package.json +++ b/packages/embed/package.json @@ -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", diff --git a/packages/embed/scripts/build-standalone.ts b/packages/embed/scripts/build-standalone.ts new file mode 100644 index 000000000..a19f9f8f5 --- /dev/null +++ b/packages/embed/scripts/build-standalone.ts @@ -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((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); diff --git a/packages/embed/src/client/protocol.ts b/packages/embed/src/client/protocol.ts index 2f5fcadcf..896fe75a5 100644 --- a/packages/embed/src/client/protocol.ts +++ b/packages/embed/src/client/protocol.ts @@ -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'; }; /** diff --git a/packages/embed/src/standalone/index.ts b/packages/embed/src/standalone/index.ts index 3751984b7..9dc1d193f 100644 --- a/packages/embed/src/standalone/index.ts +++ b/packages/embed/src/standalone/index.ts @@ -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': diff --git a/packages/embed/src/standalone/style.css b/packages/embed/src/standalone/style.css index f5b6c0f86..42455f9fb 100644 --- a/packages/embed/src/standalone/style.css +++ b/packages/embed/src/standalone/style.css @@ -37,6 +37,7 @@ color-scheme: dark; } } + * { box-sizing: border-box; } diff --git a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx index fb979e0d7..a7f4d55e1 100644 --- a/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx +++ b/packages/gitbook/src/components/Embeddable/EmbeddableIframeAPI.tsx @@ -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( - // @ts-expect-error - This is a workaround to allow the function to be optional. fn: (state: GitBookEmbeddableConfiguration) => T = (state) => state ) { return useStore(embeddableConfiguration, fn);