Match the Docs Embed widget to the page it is embedded in (#4533)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zeno Kapitein
2026-08-25 16:34:54 +02:00
committed by GitHub
parent 148a43ac01
commit 94a496c561
11 changed files with 200 additions and 45 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix the Docs Embed widget rendering with a dark surface when the visitor's OS is in dark mode, even on a light page or a site published with a light theme. The embed script now accepts `?theme=light|dark` to force a scheme.
+5
View File
@@ -0,0 +1,5 @@
---
"@gitbook/embed": patch
---
Make the Docs Embed widget match the page it is embedded in rather than the visitor's OS: a widget on a light page stays light even when the visitor's system is in dark mode, and the widget's own chrome and the docs inside it always render in the same scheme. Sites published with a single theme impose it on the widget too, since they render in it regardless. The standalone script takes `?theme=light` on its URL, and calling `init` twice now updates the options instead of throwing.
+24 -7
View File
@@ -19,15 +19,27 @@ The script is served at `https://docs.company.com/~gitbook/embed/script.js`.
You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname. You can find the embed script from your docs site settings, or you can copy the following and replace `docs.company.com` with your docs site hostname.
```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script>
```
The script initializes the widget itself, so there is nothing to call. To pin the embed to one color
scheme, put it on the script URL — it has to be known before the widget renders:
```html
<script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
```
To authenticate the visitor, call `init` with their token. Keep tokens out of the script URL: it is
publicly cacheable and ends up in server logs.
```html ```html
<script src="https://docs.company.com/~gitbook/embed/script.js"></script> <script src="https://docs.company.com/~gitbook/embed/script.js"></script>
<script> <script>
// Initialize with Authenticated Access (optional) window.GitBook('init',
window.GitBook('init',
{ siteURL: 'https://docs.company.com' }, { siteURL: 'https://docs.company.com' },
{ visitor: { token: 'your-jwt-token' } } { visitor: { token: 'your-jwt-token' } }
); );
window.GitBook('show');
</script> </script>
``` ```
@@ -464,20 +476,25 @@ visitor: {
Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop) Available in: Standalone script (via `init`), NPM package (via `getFrameURL()`), React components (as prop)
Override the embed's color scheme. When omitted, the embed follows the iframe's CSS `color-scheme`, which lets it inherit the parent page or browser preference. Override the embed's color scheme.
When omitted, the standalone widget follows the page it is embedded in — its `color-scheme`, falling back to the visitor's OS preference only when that page declares support for both. With the NPM package or the React components you own the iframe, so the embed follows the visitor's OS preference unless you pass this.
**Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL. **Note**: This is not a configuration option but rather a parameter when initializing the frame or creating the frame URL.
**Standalone script**: Pass as the second argument to `GitBook('init', options, frameOptions)` **Standalone script**: `?theme=light` on the script URL
**NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })` **NPM package**: Pass to `getFrameURL({ colorScheme: 'dark' })`
**React components**: Pass as the `colorScheme` prop on `<GitBookFrame>` **React components**: Pass as the `colorScheme` prop on `<GitBookFrame>`
- **Type**: `'light' | 'dark'` - **Type**: `'light' | 'dark'`
```javascript ```html
colorScheme: 'dark' <script src="https://docs.company.com/~gitbook/embed/script.js?theme=light"></script>
``` ```
Sites published with a single theme always render in that theme, so `colorScheme` has no effect on
them — the widget follows the site instead, to keep its chrome and the docs inside it consistent.
### `button` ### `button`
Available in: Standalone script only Available in: Standalone script only
+3 -1
View File
@@ -10,7 +10,9 @@ export type CreateGitBookOptions = {
export type GetFrameURLOptions = { export type GetFrameURLOptions = {
/** /**
* Override the color scheme used by the embedded docs. * Override the color scheme used by the embedded docs.
* When omitted, the embed follows the iframe's CSS `color-scheme`. * When omitted, the standalone widget follows the page it is embedded in, and only falls back
* to the visitor's OS preference when that page supports both schemes. Building the iframe
* yourself, the embed follows the visitor's OS preference unless you pass this.
*/ */
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
@@ -79,7 +79,6 @@ export function GitBookFrame(props: GitBookFrameProps) {
height="100%" height="100%"
allow="clipboard-write" allow="clipboard-write"
className={className} className={className}
style={colorScheme ? { colorScheme } : undefined}
/> />
); );
} }
+88 -14
View File
@@ -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 frameConfigured = false;
let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = { let frameConfiguration: GitBookEmbeddableConfiguration & StandaloneConfiguration = {
button: { button: {
label: 'Ask', label: 'Ask',
@@ -85,6 +86,47 @@ widgetWindow.classList.add('hidden');
document.body.appendChild(widgetButton); document.body.appendChild(widgetButton);
document.body.appendChild(widgetWindow); document.body.appendChild(widgetWindow);
/**
* The one scheme everything follows: the widget's chrome, the frame's URL and the docs inside it.
* Either it was configured, or we match the page we are embedded in (RND-12558).
*/
function resolveColorScheme(): 'light' | 'dark' {
const configured = frameOptions?.colorScheme;
// Callers are plain JS, so anything else — a typo, a `system` — falls back to the page rather
// than reaching the CSS and the frame's URL, where the two would disagree.
return configured === 'light' || configured === 'dark' ? configured : hostColorScheme();
}
/**
* The scheme the embedding page renders in, which is not the visitor's OS preference: a page that
* never opted into dark stays light however the OS is set.
*
* Resolving a `light-dark()` is the only way to read it. A page's *used* color scheme isn't exposed
* anywhere — the CSSOM gives computed values, and a `<meta name="color-scheme">` (the common way to
* declare it) never even reaches those.
*/
function hostColorScheme(): 'light' | 'dark' {
const probe = document.createElement('div');
// The first `color` is the fallback where `light-dark()` is unsupported: without it the probe
// would inherit the page's own text colour and a white one would read as dark.
probe.style.cssText =
'display:none;color:rgb(0,0,0);color:light-dark(rgb(0,0,0), rgb(255,255,255))';
document.body.appendChild(probe);
const used = getComputedStyle(probe).color;
probe.remove();
return used === 'rgb(255, 255, 255)' ? 'dark' : 'light';
}
/** Mirror the resolved scheme onto the widget's own chrome, and hand it back for the frame's URL. */
function applyColorScheme(): 'light' | 'dark' {
const colorScheme = resolveColorScheme();
for (const element of [widgetButton, widgetWindow]) {
element.dataset.colorScheme = colorScheme;
}
return colorScheme;
}
function getClient() { function getClient() {
if (!_client) { if (!_client) {
throw new Error( throw new Error(
@@ -102,12 +144,8 @@ function getIframe() {
widgetIframe = document.createElement('iframe'); widgetIframe = document.createElement('iframe');
widgetIframe.id = 'gitbook-widget-iframe'; widgetIframe.id = 'gitbook-widget-iframe';
widgetIframe.allow = 'clipboard-write'; widgetIframe.allow = 'clipboard-write';
if (frameOptions?.colorScheme) { // One read for both, so the docs can't come back in a different scheme than the panel.
widgetIframe.style.colorScheme = frameOptions.colorScheme; widgetIframe.src = client.getFrameURL({ ...frameOptions, colorScheme: applyColorScheme() });
}
widgetIframe.src = client.getFrameURL({
...frameOptions,
});
widgetWindow.appendChild(widgetIframe); widgetWindow.appendChild(widgetIframe);
_frame = client.createFrame(widgetIframe); _frame = client.createFrame(widgetIframe);
@@ -115,25 +153,53 @@ function getIframe() {
widgetWindow.classList.add('hidden'); widgetWindow.classList.add('hidden');
widgetButton.classList.remove('open'); widgetButton.classList.remove('open');
}); });
// A new frame starts from the site's own defaults, so replay whatever the host configured.
if (frameConfigured) {
_frame.configure(frameConfiguration);
}
} }
return { iframe: widgetIframe, frame: _frame }; return { iframe: widgetIframe, frame: _frame };
} }
const GitBook = (...args: StandaloneCalls) => { const GitBook = (...args: StandaloneCalls) => {
switch (args[0]) { switch (args[0]) {
case 'init': case 'init': {
if (_client) { // `~gitbook/embed/script.js` already calls `init`, so an integrator following the docs
throw new Error( // ends up calling it a second time. Take the new options instead of throwing: throwing
'GitBook client already initialized. Call GitBook("unload") first.' // here dropped every call queued behind it (RND-12558).
);
}
_client = createGitBook(args[1]); _client = createGitBook(args[1]);
frameOptions = args[2]; frameOptions = {
// Replace rather than merge: a call that leaves out `visitor` — a logout, another
// site — must not keep the token from the last one.
...args[2],
// Except the scheme, where the first one wins: `script.js` passes the site's own
// theme when it pins one, and that is not the integrator's to override.
colorScheme: frameOptions?.colorScheme ?? args[2]?.colorScheme,
};
const colorScheme = applyColorScheme();
// Rebuild the frame only if the new options change its URL — reloading it on the
// loader's `init` plus the integrator's would throw away a chat for nothing.
const frameURL = _client.getFrameURL({ ...frameOptions, colorScheme });
if (widgetIframe && widgetIframe.src !== frameURL) {
const wasOpen = !widgetWindow.classList.contains('hidden');
widgetIframe.remove();
widgetIframe = undefined;
_frame = undefined;
if (wasOpen) {
getIframe();
}
}
break; break;
}
case 'unload': case 'unload':
_client = undefined; _client = undefined;
_frame = undefined; _frame = undefined;
widgetIframe?.remove(); widgetIframe?.remove();
widgetIframe = undefined;
frameOptions = undefined;
frameConfigured = false;
applyColorScheme();
widgetWindow.classList.add('hidden'); widgetWindow.classList.add('hidden');
break; break;
case 'show': case 'show':
@@ -192,6 +258,7 @@ const GitBook = (...args: StandaloneCalls) => {
} }
} }
frameConfigured = true;
getIframe().frame.configure({ getIframe().frame.configure({
...frameConfiguration, ...frameConfiguration,
}); });
@@ -214,4 +281,11 @@ const precalls = (window.GitBook as GitBookStandalone | undefined)?.q ?? [];
// @ts-expect-error - GitBook is not defined in the global scope // @ts-expect-error - GitBook is not defined in the global scope
window.GitBook = GitBook; window.GitBook = GitBook;
precalls.forEach((call) => GitBook(...call)); // Replay each queued call on its own, so one that throws doesn't drop the rest.
precalls.forEach((call) => {
try {
GitBook(...call);
} catch (error) {
console.error('[gitbook:embed]', error);
}
});
+24 -8
View File
@@ -29,14 +29,30 @@
--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) { /* The widget owns the panel's surface, so it renders in the same scheme as the docs inside the
:root { * iframe. `data-color-scheme` carries that one resolved scheme — see `resolveColorScheme()` — and
--gitbook-widget-text-color: #FFFFFF; * is always set, so the declaration below only shows before the widget initializes (RND-12558).
--gitbook-widget-border-color: #202020; * The colours are declared here rather than on `:root` because `light-dark()` resolves against the
--gitbook-widget-background-translucent: rgba(15, 15, 15, 0.9); * color-scheme of the element that declares them. The bundler lowers it to a variable toggle, so
--gitbook-widget-background-translucent-hover: rgba(20, 20, 20, 0.9); * browsers without `light-dark()` still get both schemes. */
--gitbook-widget-background-solid: #f0f0f0; #gitbook-widget-button,
} #gitbook-widget-window {
color-scheme: light;
--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, #0f0f0f);
--gitbook-widget-background-solid-hover: light-dark(#FBFBFB, #141414);
}
#gitbook-widget-button[data-color-scheme="light"],
#gitbook-widget-window[data-color-scheme="light"] {
color-scheme: light;
}
#gitbook-widget-button[data-color-scheme="dark"],
#gitbook-widget-window[data-color-scheme="dark"] {
color-scheme: dark;
} }
* { * {
@@ -5,7 +5,7 @@ import type { CreateGitBookOptions } from '@gitbook/embed';
import type { RouteLayoutParams } from '@/app/utils'; import type { RouteLayoutParams } from '@/app/utils';
import { getAssetURL } from '@/lib/assets'; import { getAssetURL } from '@/lib/assets';
import { buildVersion } from '@/lib/build'; import { buildVersion } from '@/lib/build';
import { getEmbeddableStaticContext } from '@/lib/embeddable'; import { getEmbeddableStaticContext, resolveEmbeddableTheme } from '@/lib/embeddable';
export const dynamic = 'force-static'; export const dynamic = 'force-static';
@@ -21,6 +21,10 @@ export async function GET(
siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')), siteURL: context.linker.toAbsoluteURL(context.linker.toPathInSite('')),
}; };
// The theme this site pins its embeds to, if it has one. The widget paints the panel around the
// iframe, so it has to render in the same scheme as the docs inside it (RND-12558).
const siteColorScheme = resolveEmbeddableTheme(context.customization).forcedTheme;
return new Response( return new Response(
` `
(function () { (function () {
@@ -37,8 +41,18 @@ export async function GET(
const searchParams = getScriptSearchParams() const searchParams = getScriptSearchParams()
const token = searchParams.get('jwt_token'); const token = searchParams.get('jwt_token');
const initOptions = window.gitbookSettings || ${JSON.stringify(initOptions)}; const initOptions = { ...${JSON.stringify(initOptions)}, ...(window.gitbookSettings || {}) };
const initFrameOptions = token ? { visitor: { token } } : undefined; const initFrameOptions = {};
const theme = searchParams.get('theme');
// The site's own theme first: it renders in that one whatever the embedder asks for.
const colorScheme =
${JSON.stringify(siteColorScheme ?? null)} || (theme === 'light' || theme === 'dark' ? theme : null);
if (colorScheme) {
initFrameOptions.colorScheme = colorScheme;
}
if (token) {
initFrameOptions.visitor = { token };
}
if (typeof gb === "function") { if (typeof gb === "function") {
gb('init', initOptions, initFrameOptions); gb('init', initOptions, initFrameOptions);
@@ -38,10 +38,10 @@ export async function EmbeddableRootLayout({
}: React.PropsWithChildren<EmbeddableRootLayoutProps>) { }: React.PropsWithChildren<EmbeddableRootLayoutProps>) {
const theme = resolveEmbeddableTheme(context.customization, forcedTheme); const theme = resolveEmbeddableTheme(context.customization, forcedTheme);
// Only remember a theme the visitor explicitly requested via `?theme=`, and only on // Only remember a theme that was explicitly requested via `?theme=` *and* honored — a site
// multi-theme sites that actually honor the override. Single-theme sites ignore it (and would // pinned to one theme ignores the request, and would otherwise persist its own default on every
// otherwise persist their own default on every plain visit — see PR #4380 review). RND-11571 // plain visit (see PR #4380 review). RND-11571
const rememberedTheme = context.customization.themes.toggeable ? forcedTheme : null; const rememberedTheme = theme.forcedTheme === forcedTheme ? forcedTheme : null;
return ( return (
<CustomizationRootLayout <CustomizationRootLayout
@@ -182,3 +182,20 @@ describe('resolveEmbeddableTheme', () => {
}); });
}); });
}); });
describe('resolveEmbeddableTheme with a single system theme', () => {
it('accepts an explicit override, since the site pins no theme', () => {
// The widget outside the iframe follows the embedding page for these sites, so the docs
// have to honor the scheme it asks for or the two disagree (RND-12558).
expect(
resolveEmbeddableTheme(
{ themes: { toggeable: false, default: CustomizationDefaultThemeMode.System } },
CustomizationDefaultThemeMode.Light
)
).toEqual({
htmlTheme: CustomizationDefaultThemeMode.Light,
defaultTheme: CustomizationDefaultThemeMode.Light,
forcedTheme: CustomizationDefaultThemeMode.Light,
});
});
});
+13 -7
View File
@@ -48,21 +48,27 @@ export async function getEmbeddableDynamicContext(params: RouteLayoutParams) {
export { getEmbeddableLinker } from './embeddable-linker'; export { getEmbeddableLinker } from './embeddable-linker';
/** /**
* Resolve theme behavior for docs embeds. * Resolve the theme the embed renders in: the site's own when it publishes a single theme, an
* Embeds should follow the parent frame's color-scheme by default, * explicit `?theme=` override when it publishes both, and otherwise the visitor's OS.
* while still allowing an explicit override for multi-theme sites. *
* `forcedTheme` is also what the embed is pinned to whatever the embedder asks for, which is what
* the widget outside the iframe has to follow (RND-12558).
*/ */
export function resolveEmbeddableTheme( export function resolveEmbeddableTheme(
customization: Pick<SiteCustomizationSettings, 'themes'>, customization: Pick<SiteCustomizationSettings, 'themes'>,
forcedTheme?: CustomizationDefaultThemeMode | null forcedTheme?: CustomizationDefaultThemeMode | null
) { ) {
if (!customization.themes.toggeable) { const mode = customization.themes.default;
const mode = customization.themes.default; // A site published in one concrete theme renders in it whatever the embedder asks for: its
// content only exists for that theme. `System` pins nothing, so an override applies there —
// and it has to, or the widget outside would follow the page while the docs follow the OS
// (RND-12558). Unforced `System` also lets next-themes resolve prefers-color-scheme pre-paint,
// avoiding a flash (RND-11643).
if (!customization.themes.toggeable && mode !== CustomizationDefaultThemeMode.System) {
return { return {
htmlTheme: mode, htmlTheme: mode,
defaultTheme: mode, defaultTheme: mode,
// Only force concrete light/dark; System stays unforced so next-themes resolves prefers-color-scheme pre-paint (avoids the flash). A theme saved while the toggle was previously on still wins — see the PR's "Known limitation". RND-11643 forcedTheme: mode,
forcedTheme: mode === CustomizationDefaultThemeMode.System ? undefined : mode,
}; };
} }