diff --git a/.changeset/curvy-carrots-melt.md b/.changeset/curvy-carrots-melt.md new file mode 100644 index 0000000000..c1d13c3ec9 --- /dev/null +++ b/.changeset/curvy-carrots-melt.md @@ -0,0 +1,5 @@ +--- +'gitbook': patch +--- + +Add support for tint color diff --git a/bun.lockb b/bun.lockb index 4d4fa7786a..6c00827f05 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/gitbook/e2e/pages.spec.ts b/packages/gitbook/e2e/pages.spec.ts index 8476da0def..845be06aad 100644 --- a/packages/gitbook/e2e/pages.spec.ts +++ b/packages/gitbook/e2e/pages.spec.ts @@ -1,15 +1,19 @@ import { argosScreenshot } from '@argos-ci/playwright'; import { CustomizationBackground, + CustomizationCorners, + CustomizationFont, CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationLocale, + CustomizationThemeMode, SiteCustomizationSettings, } from '@gitbook/api'; import { test, expect, Page } from '@playwright/test'; import jwt from 'jsonwebtoken'; import rison from 'rison'; import { DeepPartial } from 'ts-essentials'; +import deepMerge from 'deepmerge'; import { getContentTestURL } from '../tests/utils'; @@ -35,6 +39,18 @@ const allLocales: CustomizationLocale[] = [ CustomizationLocale.Zh, ]; +const allThemeModes: CustomizationThemeMode[] = [ + CustomizationThemeMode.Light, + CustomizationThemeMode.Dark, +]; + +const allThemePresets: CustomizationHeaderPreset[] = [ + CustomizationHeaderPreset.Default, + CustomizationHeaderPreset.Bold, + CustomizationHeaderPreset.Contrast, + CustomizationHeaderPreset.Custom, +]; + async function waitForCookiesDialog(page: Page) { const dialog = page.getByRole('dialog', { name: 'Cookies' }); const accept = dialog.getByRole('button', { name: 'Accept' }); @@ -436,30 +452,38 @@ const testCases: TestsCase[] = [ { name: 'Customization', baseUrl: 'https://gitbook.gitbook.io/test-gitbook-open/', - tests: [ + tests: allThemeModes.flatMap((theme) => [ { - name: 'Without header', + name: `Without header - Theme ${theme}`, url: getCustomizationURL({ header: { preset: CustomizationHeaderPreset.None, links: [], }, + themes: { + default: theme, + toggeable: false, + }, }), run: waitForCookiesDialog, }, { - name: 'With duotone icons', + name: `With duotone icons - Theme ${theme}`, url: 'page-options/page-with-icon' + getCustomizationURL({ styling: { icons: CustomizationIconsStyle.Duotone, }, + themes: { + default: theme, + toggeable: false, + }, }), run: waitForCookiesDialog, }, { - name: 'With header buttons', + name: `With header buttons - Theme ${theme}`, url: getCustomizationURL({ header: { preset: CustomizationHeaderPreset.Default, @@ -476,39 +500,16 @@ const testCases: TestsCase[] = [ }, ], }, - }), - run: waitForCookiesDialog, - }, - { - name: 'With tint - Legacy background match', - url: getCustomizationURL({ - styling: { - background: CustomizationBackground.Match, - }, - header: { - preset: CustomizationHeaderPreset.Default, - links: [ - { - title: 'Secondary button', - to: { kind: 'url', url: 'https://www.gitbook.com' }, - style: 'button-secondary', - }, - { - title: 'Primary button', - to: { kind: 'url', url: 'https://www.gitbook.com' }, - style: 'button-primary', - }, - ], + themes: { + default: theme, + toggeable: false, }, }), run: waitForCookiesDialog, }, { - name: 'With tint - Default preset', + name: `Without tint - Default preset - Theme ${theme}`, url: getCustomizationURL({ - styling: { - tint: { color: { light: '#346DDB', dark: '#346DDB' } }, - }, header: { preset: CustomizationHeaderPreset.Default, links: [ @@ -524,41 +525,27 @@ const testCases: TestsCase[] = [ }, ], }, - }), - run: waitForCookiesDialog, - }, - { - name: 'With tint - Bold preset', - url: getCustomizationURL({ - styling: { - tint: { color: { light: '#346DDB', dark: '#346DDB' } }, - }, - header: { - preset: CustomizationHeaderPreset.Bold, - links: [ - { - title: 'Secondary button', - to: { kind: 'url', url: 'https://www.gitbook.com' }, - style: 'button-secondary', - }, - { - title: 'Primary button', - to: { kind: 'url', url: 'https://www.gitbook.com' }, - style: 'button-primary', - }, - ], + themes: { + default: theme, + toggeable: false, }, }), run: waitForCookiesDialog, }, - { - name: 'With tint - Contrast', + ...allThemePresets.flatMap((preset) => ({ + name: `With tint - Preset ${preset} - Theme ${theme}`, url: getCustomizationURL({ styling: { tint: { color: { light: '#346DDB', dark: '#346DDB' } }, }, header: { - preset: CustomizationHeaderPreset.Contrast, + preset, + ...(preset === CustomizationHeaderPreset.Custom + ? { + backgroundColor: { light: '#C62C68', dark: '#EF96B8' }, + linkColor: { light: '#4DDE98', dark: '#0C693D' }, + } + : {}), links: [ { title: 'Secondary button', @@ -572,19 +559,21 @@ const testCases: TestsCase[] = [ }, ], }, + themes: { + default: theme, + toggeable: false, + }, }), run: waitForCookiesDialog, - }, + })), { - name: 'With tint - Custom preset', + name: `With tint - Legacy background match - Theme ${theme}`, url: getCustomizationURL({ styling: { - tint: { color: { light: '#346DDB', dark: '#346DDB' } }, + background: CustomizationBackground.Match, }, header: { - preset: CustomizationHeaderPreset.Custom, - backgroundColor: { light: '#C62C68', dark: '#EF96B8' }, - linkColor: { light: '#4DDE98', dark: '#0C693D' }, + preset: CustomizationHeaderPreset.Default, links: [ { title: 'Secondary button', @@ -598,10 +587,14 @@ const testCases: TestsCase[] = [ }, ], }, + themes: { + default: theme, + toggeable: false, + }, }), run: waitForCookiesDialog, }, - ], + ]), }, { name: 'Ads', @@ -971,7 +964,62 @@ for (const testCase of testCases) { * Create a URL with customization settings. */ function getCustomizationURL(partial: DeepPartial): string { - const encoded = rison.encode_object(partial); + /** + * Default customization settings. + * + * The customization object passed to the URL should be a valid API settings object. Hence we extend the test with necessary defaults. + */ + const DEFAULT_CUSTOMIZATION: SiteCustomizationSettings = { + styling: { + primaryColor: { light: '#346DDB', dark: '#346DDB' }, + corners: CustomizationCorners.Rounded, + font: CustomizationFont.Inter, + background: CustomizationBackground.Plain, + icons: CustomizationIconsStyle.Regular, + }, + internationalization: { + locale: CustomizationLocale.En, + }, + favicon: {}, + header: { + preset: CustomizationHeaderPreset.Default, + links: [], + }, + footer: { + groups: [], + }, + themes: { + default: CustomizationThemeMode.Light, + toggeable: true, + }, + pdf: { + enabled: true, + }, + feedback: { + enabled: false, + }, + aiSearch: { + enabled: true, + }, + advancedCustomization: { + enabled: true, + }, + git: { + showEditLink: false, + }, + pagination: { + enabled: true, + }, + trademark: { + enabled: true, + }, + privacyPolicy: { + url: 'https://www.gitbook.com/privacy', + }, + socialPreview: {}, + }; + + const encoded = rison.encode_object(deepMerge(DEFAULT_CUSTOMIZATION, partial)); const searchParams = new URLSearchParams(); searchParams.set('customization', encoded); diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index 493916034b..6e196cfe2f 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -68,9 +68,9 @@ }, "devDependencies": { "@argos-ci/playwright": "^2.0.0", - "@playwright/test": "^1.42.1", "@cloudflare/next-on-pages": "^1.13.5", "@cloudflare/workers-types": "^4.20240725.0", + "@playwright/test": "^1.42.1", "@types/js-cookie": "^3.0.6", "@types/jsontoxml": "^1.0.5", "@types/jsonwebtoken": "^9.0.6", @@ -82,6 +82,8 @@ "@types/react-dom": "18.3.1", "@types/rison": "^0.0.9", "autoprefixer": "^10", + "deepmerge": "^4.3.1", + "env-cmd": "^10.1.0", "eslint": "^8", "eslint-config-next": "^14.2.5", "eslint-plugin-import": "^2.29.1", @@ -89,8 +91,7 @@ "postcss": "^8", "psi": "^4.1.0", "tailwindcss": "^3.4.0", - "typescript": "^5.5.3", - "env-cmd": "^10.1.0", - "ts-essentials": "^10.0.1" + "ts-essentials": "^10.0.1", + "typescript": "^5.5.3" } } diff --git a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css index 91ce96fe7a..5f748dd65f 100644 --- a/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css +++ b/packages/gitbook/src/components/DocumentView/OpenAPI/scalar.css @@ -5,7 +5,7 @@ .light .scalar { --scalar-color-1: color-mix( in srgb, - rgb(var(--primary-base-300, 180 180 180)), + rgb(var(--tint-color-300, 180 180 180)), rgb(var(--dark-base, 23 23 23)) 96% ); --scalar-color-2: color-mix( @@ -23,12 +23,12 @@ --scalar-background-1: rgb(var(--light-base, 255 255 255)); --scalar-background-2: color-mix( in srgb, - rgb(var(--primary-base-800, 30 30 30)), + rgb(var(--tint-color-800, 30 30 30)), var(--scalar-background-1) 96% ); --scalar-background-3: color-mix( in srgb, - rgb(var(--primary-base-800, 30 30 30)), + rgb(var(--tint-color-800, 30 30 30)), var(--scalar-background-1) 90% ); --scalar-background-accent: #007d9c1f; @@ -66,7 +66,7 @@ .dark .scalar { --scalar-color-1: color-mix( in srgb, - rgb(var(--primary-base-700, 70 70 70)), + rgb(var(--tint-color-700, 70 70 70)), rgb(var(--light-base, 255 255 255)) 100% ); --scalar-color-2: color-mix( @@ -84,12 +84,12 @@ --scalar-background-1: rgb(var(--dark-base, 22 22 22)); --scalar-background-2: color-mix( in srgb, - rgb(var(--primary-base-200, 200 200 200)), + rgb(var(--tint-color-200, 200 200 200)), var(--scalar-background-1) 92% ); --scalar-background-3: color-mix( in srgb, - rgb(var(--primary-base-200, 200 200 200)), + rgb(var(--tint-color-200, 200 200 200)), var(--scalar-background-1) 88% ); --scalar-background-accent: #8ab4f81f; diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index 5b280374ed..05475df6d7 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -1,9 +1,9 @@ import { - CustomizationBackground, CustomizationCorners, CustomizationHeaderPreset, CustomizationIconsStyle, CustomizationSettings, + CustomizationTint, SiteCustomizationSettings, } from '@gitbook/api'; import { IconsProvider, IconStyle } from '@gitbook/icons'; @@ -17,11 +17,13 @@ import { getStaticFileURL } from '@/lib/assets'; import { hexToRgb, shadesOfColor } from '@/lib/colors'; import { tcls } from '@/lib/tailwind'; -import { ClientContexts } from './ClientContexts'; import { emojiFontClassName } from '../primitives'; +import { ClientContexts } from './ClientContexts'; -import './globals.css'; import '@gitbook/icons/style.css'; +import './globals.css'; + +const DEFAULT_TINT_COLOR = '#787878'; /** * Layout shared between the content and the PDF renderer. @@ -36,26 +38,21 @@ export async function CustomizationRootLayout(props: { const headerTheme = generateHeaderTheme(customization); const language = getSpaceLanguage(customization); + const tintColor = getTintColor(customization); + return ( {customization.privacyPolicy.url ? ( @@ -87,10 +84,22 @@ export async function CustomizationRootLayout(props: { ) } - ${generateColorVariable( - 'primary-base', - customization.styling.primaryColor.light, - )} + ${generateColorVariable('tint-color', tintColor?.light ?? DEFAULT_TINT_COLOR)} + ${ + // Generate the right contrast color for each shade of tint-color + generateColorVariable( + 'contrast-tint', + Object.fromEntries( + Object.entries( + shadesOfColor(tintColor?.light || DEFAULT_TINT_COLOR), + ).map(([index, color]) => [ + index, + colorContrast(color, ['#000', '#fff']), + ]), + ), + ) + } + ${generateColorVariable( 'header-background', headerTheme.backgroundColor.light, @@ -103,10 +112,6 @@ export async function CustomizationRootLayout(props: { 'primary-color', customization.styling.primaryColor.dark, )} - ${generateColorVariable( - 'primary-base', - customization.styling.primaryColor.dark, - )} ${ // Generate the right contrast color for each shade of primary-color generateColorVariable( @@ -121,6 +126,23 @@ export async function CustomizationRootLayout(props: { ), ) } + + ${generateColorVariable('tint-color', tintColor?.dark ?? DEFAULT_TINT_COLOR)} + ${ + // Generate the right contrast color for each shade of tint-color + generateColorVariable( + 'contrast-tint', + Object.fromEntries( + Object.entries( + shadesOfColor(tintColor?.dark || DEFAULT_TINT_COLOR), + ).map(([index, color]) => [ + index, + colorContrast(color, ['#000', '#fff']), + ]), + ), + ) + } + ${generateColorVariable( 'header-background', headerTheme.backgroundColor.dark, @@ -160,6 +182,21 @@ export async function CustomizationRootLayout(props: { ); } +/** + * Get the tint color from the customization settings. + * If the tint color is not set or it is a space customization, it will return the default color. + */ +export function getTintColor( + customization: CustomizationSettings | SiteCustomizationSettings, +): CustomizationTint['color'] | undefined { + if ('tint' in customization.styling && customization.styling.tint) { + return { + light: customization.styling.tint?.color.light ?? DEFAULT_TINT_COLOR, + dark: customization.styling.tint?.color.dark ?? DEFAULT_TINT_COLOR, + }; + } +} + type ColorInput = string | Record; function generateColorVariable(name: string, color: ColorInput) { const shades: Record = typeof color === 'string' ? shadesOfColor(color) : color; @@ -177,6 +214,8 @@ function generateHeaderTheme(customization: CustomizationSettings | SiteCustomiz backgroundColor: { light: ColorInput; dark: ColorInput }; linkColor: { light: ColorInput; dark: ColorInput }; } { + const tintColor = getTintColor(customization); + switch (customization.header.preset) { case CustomizationHeaderPreset.None: case CustomizationHeaderPreset.Default: { @@ -194,17 +233,17 @@ function generateHeaderTheme(customization: CustomizationSettings | SiteCustomiz case CustomizationHeaderPreset.Bold: { return { backgroundColor: { - light: customization.styling.primaryColor.light, - dark: customization.styling.primaryColor.dark, + light: tintColor?.light ?? customization.styling.primaryColor.light, + dark: tintColor?.dark ?? customization.styling.primaryColor.dark, }, linkColor: { light: colorContrast( - customization.styling.primaryColor.light, + tintColor?.light ?? customization.styling.primaryColor.light, [colors.white, colors.black], 'aaa', ), dark: colorContrast( - customization.styling.primaryColor.dark, + tintColor?.dark ?? customization.styling.primaryColor.dark, [colors.white, colors.black], 'aaa', ), @@ -226,15 +265,25 @@ function generateHeaderTheme(customization: CustomizationSettings | SiteCustomiz case CustomizationHeaderPreset.Custom: { return { backgroundColor: { - light: customization.header.backgroundColor?.light ?? colors.white, - dark: customization.header.backgroundColor?.dark ?? colors.black, + light: + customization.header.backgroundColor?.light ?? + tintColor?.light ?? + colors.white, + dark: + customization.header.backgroundColor?.dark ?? + tintColor?.dark ?? + colors.black, }, linkColor: { light: customization.header.linkColor?.light ?? + (tintColor?.light && + colorContrast(tintColor.light, [colors.white, colors.black], 'aaa')) ?? customization.styling.primaryColor.light, dark: customization.header.linkColor?.dark ?? + (tintColor?.dark && + colorContrast(tintColor.dark, [colors.white, colors.black], 'aaa')) ?? customization.styling.primaryColor.dark, }, }; diff --git a/packages/gitbook/src/components/RootLayout/globals.css b/packages/gitbook/src/components/RootLayout/globals.css index 4b5f7b3df3..961a58fe6b 100644 --- a/packages/gitbook/src/components/RootLayout/globals.css +++ b/packages/gitbook/src/components/RootLayout/globals.css @@ -7,27 +7,19 @@ --scrollbar-width: calc(100vw - 100%); --dark-base: 20 20 20; - --light-base: 251 252 252; - - --light-1: color-mix(in srgb, rgb(var(--primary-base-600)), rgb(var(--light-base)) 99%); - --light-DEFAULT: color-mix( - in srgb, - rgb(var(--primary-base-700)), - rgb(var(--light-base)) 96% - ); - --light-2: color-mix(in srgb, rgb(var(--primary-base-800)), rgb(var(--light-base)) 92%); - --light-3: color-mix(in srgb, rgb(var(--primary-base-800)), rgb(var(--light-base)) 88%); - --light-4: color-mix(in srgb, rgb(var(--primary-base-800)), rgb(var(--light-base)) 72%); - - --dark-1: color-mix(in srgb, rgb(var(--primary-base-100)), rgb(1 1 1) 96%); - --dark-DEFAULT: color-mix( - in srgb, - rgb(var(--primary-base-300)), - rgb(var(--dark-base)) 94.5% - ); - --dark-2: color-mix(in srgb, rgb(var(--primary-base-200)), rgb(var(--dark-base)) 92%); - --dark-3: color-mix(in srgb, rgb(var(--primary-base-200)), rgb(25 25 25) 91%); - --dark-4: color-mix(in srgb, rgb(var(--primary-base-200)), rgb(var(--dark-base)) 64%); + --light-base: 251 251 251; + + --light-1: color-mix(in srgb, rgb(var(--tint-color-600)), rgb(var(--light-base)) 99%); + --light-DEFAULT: color-mix(in srgb, rgb(var(--tint-color-700)), rgb(var(--light-base)) 96%); + --light-2: color-mix(in srgb, rgb(var(--tint-color-700)), rgb(var(--light-base)) 94%); + --light-3: color-mix(in srgb, rgb(var(--tint-color-800)), rgb(var(--light-base)) 88%); + --light-4: color-mix(in srgb, rgb(var(--tint-color-800)), rgb(var(--light-base)) 72%); + + --dark-1: color-mix(in srgb, rgb(var(--tint-color-400)), rgb(var(--dark-base)) 96%); + --dark-DEFAULT: color-mix(in srgb, rgb(var(--tint-color-300)), rgb(var(--dark-base)) 94%); + --dark-2: color-mix(in srgb, rgb(var(--tint-color-300)), rgb(var(--dark-base)) 92%); + --dark-3: color-mix(in srgb, rgb(var(--tint-color-200)), rgb(var(--dark-base)) 91%); + --dark-4: color-mix(in srgb, rgb(var(--tint-color-200)), rgb(var(--dark-base)) 64%); @apply leading-relaxed; } body { @@ -36,29 +28,13 @@ html { @apply gutter-stable; } - html:is(.plain-background) { - --dark-base: 22 22 22; + html:is(.no-tint) { --light-base: 255 255 255; - /* reset primaries as greys */ - --primary-base-50: 239 239 239; - --primary-base-100: 222 222 222; - --primary-base-200: 200 200 200; - --primary-base-300: 180 180 180; - --primary-base-400: 150 150 150; - --primary-base-500: 120 120 120; - --primary-base-600: 90 90 90; - --primary-base-700: 70 70 70; - --primary-base-800: 30 30 30; - --primary-base-900: 19 19 19; - --light-DEFAULT: color-mix( in srgb, - rgb(var(--primary-base-700)), + rgb(var(--tint-color-500)), rgb(var(--light-base)) 100% ); - --light-2: color-mix(in srgb, rgb(var(--primary-base-800)), rgb(var(--light-base)) 96%); - --light-3: color-mix(in srgb, rgb(var(--primary-base-800)), rgb(var(--light-base)) 90%); - --light-4: color-mix(in srgb, rgb(var(--primary-base-800)), rgb(var(--light-base)) 76%); } h1 { @apply tracking-[-0.025em] text-dark dark:text-light text-balance; diff --git a/packages/gitbook/src/lib/api.ts b/packages/gitbook/src/lib/api.ts index f5dab8018f..b1b3ce8e5c 100644 --- a/packages/gitbook/src/lib/api.ts +++ b/packages/gitbook/src/lib/api.ts @@ -779,7 +779,7 @@ export async function getSiteData( const spaces = siteSpaces ?? (sections ? parseSpacesFromSiteSpaces(sections.section.siteSpaces) : []); - const customization = mergeCustomizationWithExtend( + const customization = getActiveCustomizationSettings( pointer.siteSpaceId ? customizations.siteSpaces[pointer.siteSpaceId] : customizations.site, ); @@ -1204,19 +1204,19 @@ async function getAll( } /** - * Merge the customization settings with the ones passed in the x-gitbook-customization header if present. + * Selects the customization settings from the x-gitbook-customization header if present, + * otherwise returns the original API-provided settings. */ -function mergeCustomizationWithExtend( - raw: T, -) { +function getActiveCustomizationSettings( + settings: SiteCustomizationSettings, +): SiteCustomizationSettings { const headersList = headers(); const extend = headersList.get('x-gitbook-customization'); if (extend) { try { - const parsed = rison.decode_object>(extend); + const parsedSettings = rison.decode_object(extend); - // Merge objects and some properties deep - return mergeDeepPlainObject(raw, parsed, ['styling', 'themes']); + return parsedSettings; } catch (error) { console.error( `Failed to parse x-gitbook-customization header (ignored): ${ @@ -1226,35 +1226,5 @@ function mergeCustomizationWithExtend(target: T, source: DeepPartial, keys: Array): T { - if (typeof target !== 'object' || target === null) { - return target; - } - - const result = { ...target }; - - for (const key in source) { - const value = source[key]; - if (value === undefined) { - continue; - } - - if ( - typeof value === 'object' && - !Array.isArray(value) && - value !== null && - keys.includes(key as keyof T) - ) { - // @ts-ignore - result[key] = mergeDeepPlainObject(target[key] ?? {}, value, []); - } else { - // @ts-ignore - result[key] = value; - } - } - - return result; + return settings; } diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index 7a2266b017..2f13cc77a7 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -72,9 +72,8 @@ const config: Config = { /** primary-color used to accent elements, these colors remain unchanged when toggling between the CustomizationBackground options**/ primary: generateVarShades('primary-color'), 'contrast-primary': generateVarShades('contrast-primary'), - - /** primary-base is an internal color that generates the same colors as primary-color. But it's shades will change into a grayscale if CustomizationBackground.Plain is selected. (globals.css) **/ - primarybase: generateVarShades('primary-base'), + tint: generateVarShades('primary-color'), + 'contrast-tint': generateVarShades('contrast-primary'), 'header-background': generateVarShades('header-background'), 'header-link': generateVarShades('header-link'), @@ -232,7 +231,12 @@ const config: Config = { /** * Variant when the space is configured with a theme matching background. */ - addVariant('plain-background', 'html.plain-background &'); + addVariant('tint', 'html.tint &'); + + /** + * Variant when the space is configured without a theme matching background. + */ + addVariant('no-tint', 'html.no-tint &'); /** * Variant when the page contains a block that will be rendered in full-width mode.