From d3bfb9f1ba7079a49df96a3ec1bb1648814724aa Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Wed, 19 Feb 2025 16:12:58 +0100 Subject: [PATCH 1/5] add @gitbook/colors --- bun.lock | 12 + packages/colors/README.md | 3 + packages/colors/package.json | 25 ++ packages/colors/src/colors.ts | 39 +++ packages/colors/src/index.ts | 2 + packages/colors/src/transformations.ts | 408 +++++++++++++++++++++++++ packages/colors/tsconfig.json | 24 ++ 7 files changed, 513 insertions(+) create mode 100644 packages/colors/README.md create mode 100644 packages/colors/package.json create mode 100644 packages/colors/src/colors.ts create mode 100644 packages/colors/src/index.ts create mode 100644 packages/colors/src/transformations.ts create mode 100644 packages/colors/tsconfig.json diff --git a/bun.lock b/bun.lock index d0591adc22..d944df9614 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,16 @@ "wrangler": "3.82.0", }, }, + "packages/colors": { + "name": "@gitbook/colors", + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.5.3", + }, + "peerDependencies": { + "react": "*", + }, + }, "packages/emoji-codepoints": { "name": "@gitbook/emoji-codepoints", "version": "0.2.0", @@ -594,6 +604,8 @@ "@gitbook/cache-do": ["@gitbook/cache-do@workspace:packages/cache-do"], + "@gitbook/colors": ["@gitbook/colors@workspace:packages/colors"], + "@gitbook/emoji-codepoints": ["@gitbook/emoji-codepoints@workspace:packages/emoji-codepoints"], "@gitbook/fontawesome-pro": ["@gitbook/fontawesome-pro@1.0.8", "", { "dependencies": { "@fortawesome/fontawesome-common-types": "^6.6.0" } }, "sha512-i4PgiuGyUb52Muhc52kK3aMJIMfMkA2RbPW30tre8a6M8T6mWTfYo6gafSgjNvF1vH29zcuB8oBYnF0gO4XcHA=="], diff --git a/packages/colors/README.md b/packages/colors/README.md new file mode 100644 index 0000000000..cadefb7bbf --- /dev/null +++ b/packages/colors/README.md @@ -0,0 +1,3 @@ +# `@gitbook/colors` + +A set of default colors and transformation functions used throughout the GitBook Open and app. diff --git a/packages/colors/package.json b/packages/colors/package.json new file mode 100644 index 0000000000..8b46045b84 --- /dev/null +++ b/packages/colors/package.json @@ -0,0 +1,25 @@ +{ + "name": "@gitbook/colors", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "development": "./src/index.ts", + "default": "./dist/index.js" + } + }, + "version": "0.1.0", + "devDependencies": { + "typescript": "^5.5.3" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "dev": "tsc -w" + }, + "files": [ + "src", + "README.md", + "CHANGELOG.md" + ] +} diff --git a/packages/colors/src/colors.ts b/packages/colors/src/colors.ts new file mode 100644 index 0000000000..81061cadef --- /dev/null +++ b/packages/colors/src/colors.ts @@ -0,0 +1,39 @@ +/** + * Default primary color throughout the GitBook ecosystem. + */ +export const DEFAULT_PRIMARY_COLOR = '#346DDB'; + +/** + * + */ +export const DARK_BASE = '#1D1D1D'; + +/** + * + */ +export const LIGHT_BASE = '#FFFFFF'; + +/** + * + */ +export const DEFAULT_TINT_COLOR = '#787878'; + +/** + * + */ +export const DEFAULT_HINT_INFO_COLOR = '#787878'; + +/** + * + */ +export const DEFAULT_HINT_WARNING_COLOR = '#FE9A00'; + +/** + * + */ +export const DEFAULT_HINT_DANGER_COLOR = '#FB2C36'; + +/** + * + */ +export const DEFAULT_HINT_SUCCESS_COLOR = '#00C950'; diff --git a/packages/colors/src/index.ts b/packages/colors/src/index.ts new file mode 100644 index 0000000000..3ae4e1ad64 --- /dev/null +++ b/packages/colors/src/index.ts @@ -0,0 +1,2 @@ +export * from './colors'; +export * from './transformations'; diff --git a/packages/colors/src/transformations.ts b/packages/colors/src/transformations.ts new file mode 100644 index 0000000000..b361016668 --- /dev/null +++ b/packages/colors/src/transformations.ts @@ -0,0 +1,408 @@ +import { DARK_BASE, LIGHT_BASE, DEFAULT_TINT_COLOR } from './colors'; + +type ColorShades = { + [key: string]: string; +}; + +type RGBColor = [number, number, number]; +type OKLABColor = { L: number; A: number; B: number }; +type OKLCHColor = { L: number; C: number; H: number }; + +const D65 = [95.047, 100.0, 108.883]; // Reference white (D65) + +export enum ColorCategory { + backgrounds = 'backgrounds', + components = 'components', + borders = 'borders', + accents = 'accents', + text = 'text', +} + +type ColorSubScale = { + [key: string]: number; +}; + +/** + * Main color scale object. + * + * Each `ColorCategory` can be in/excluded in Tailwind's utility classes generation. + * Each subitem maps a semantic name within that category to a step in the scale. + */ +export const scale: Record = { + [ColorCategory.backgrounds]: { + /** Base background */ + base: 1, + /** Accent background */ + subtle: 2, + }, + [ColorCategory.components]: { + /** Component background */ + DEFAULT: 3, + /** Component hover background */ + hover: 4, + /** Component active background */ + active: 5, + }, + [ColorCategory.borders]: { + /** Subtle borders, separators */ + subtle: 6, + /** Element border, focus rings */ + DEFAULT: 7, + /** Element hover border */ + hover: 8, + }, + [ColorCategory.accents]: { + /** Solid backgrounds */ + solid: 9, + /** Hovered solid backgrounds */ + 'solid-hover': 10, + }, + [ColorCategory.text]: { + /** Very low-contrast text + * Caution: this contrast does not meet accessiblity guidelines. + * Always check if you need to include a mitigating contrast-more style for users who need it. */ + subtle: 9, + /** Low-contrast text */ + DEFAULT: 11, + /** High-contrast text */ + strong: 12, + }, +}; + +/** + * The mix of foreground and background for every step in a colour scale. + * 0: 100% of the background color's luminosity, white in light mode + * 1: 100% of the foreground color's luminosity, black in light mode + */ +export const colorMixMapping = { + // bgs |components |borders |solid |text + light: [0, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.5, 0.55, 0.6, 1], + dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1], +}; + +/** + * Convert a hex color to an RGB color. + */ +export function hexToRgb(hex: string): string { + const [r, g, b] = hexToRgbArray(hex); + // Return the RGB values separated by spaces + return `${r} ${g} ${b}`; +} + +/** + * Convert a hex color to a RGBA color. + */ +export function hexToRgba(hex: string, alpha: number): string { + const [r, g, b] = hexToRgbArray(hex); + // Return the RGBA values separated by spaces + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +} + +/** + * Generate Tailwind-compatible shades from a single color + * @param {string} hex The hex code to generate shades from + * @param {boolean} halfShades Generate additional shades, e.g. at 150 + * @returns {{[key: number]: string}} + */ +export function shadesOfColor(hex: string, halfShades = false) { + const baseColor = hex; + + const shades = [ + 50, + 100, + 200, + 300, + 400, + 500, + 600, + 700, + 800, + 900, + ...(halfShades ? [150, 250, 350, 450, 550, 650, 750, 850] : []), + ].sort(); + + const result: ColorShades = {}; + + for (const shade of shades) { + const key = shade.toString(); + + if (shade === 500) { + result[key] = hex; + continue; + } + + let shadeIndex = shade; + const isDarkShade = shadeIndex > 500; + if (isDarkShade) { + shadeIndex -= 500; + } + + const percentage = shadeIndex / 500; + const startColor = isDarkShade ? DARK_BASE : baseColor; + const endColor = isDarkShade ? baseColor : LIGHT_BASE; + + result[key] = getColor(percentage, hexToRgbArray(startColor), hexToRgbArray(endColor)); + } + + return result; +} + +export type ColorScaleOptions = { + /** If set to `true`, inverts the scale (so 1 is black instead of white) and uses `colorMixMapping.dark` with different mix ratios per step. */ + darkMode?: boolean; + + /** Define a custom background color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */ + background?: string; + + /** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */ + foreground?: string; + + mix?: { + /** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */ + color: string; + + /** Define a custom mix ratio to mix the `mix` color with. If left undefined, the default ratio will be used. */ + ratio: number; + }; +}; + +/** + * Generate a [Radix-like](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) colour scale based of a hex colour. + * @param {string} hex The hex code to generate shades from + * @param {object} options + */ +export function colorScale( + hex: string, + { + darkMode = false, + background = darkMode ? DARK_BASE : LIGHT_BASE, + foreground = darkMode ? LIGHT_BASE : DARK_BASE, + mix, + }: ColorScaleOptions = {}, +) { + const baseColor = rgbToOklch(hexToRgbArray(hex)); + const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null; + const foregroundColor = rgbToOklch(hexToRgbArray(foreground)); + const backgroundColor = rgbToOklch(hexToRgbArray(background)); + + if (mixColor && mix?.ratio && mix.ratio > 0) { + // If defined, we mix in a (tiny) bit of the mix color with the base color. + baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio); + baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio); + baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H; + } + + const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light; + + const result = []; + + for (let index = 0; index < mapping.length; index++) { + const targetL = + foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]); + + if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) { + // Original colour is close enough to target, so let's use the original colour as step 9. + result.push(hex); + continue; + } + + const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1; + + const shade = { + L: targetL, // Blend lightness + C: baseColor.C * chromaRatio, + H: baseColor.H, // Maintain the hue from the base color + }; + + const newHex = rgbArrayToHex(oklchToRgb(shade)); + + result.push(newHex); + } + + return result; +} + +/** + * Convert a hex color to an RGB color set. + */ +export function hexToRgbArray(hex: string): RGBColor { + const originalHex = hex; + + let value = hex.replace('#', ''); + if (hex.length === 3) value = value + value; + + const r = value.substring(0, 2); + const g = value.substring(2, 4); + const b = value.substring(4, 6); + + const rgb = [r, g, b].map((channel) => { + try { + const channelInt = Number.parseInt(channel, 16); + if (channelInt < 0 || channelInt > 255) throw new Error(); + return channelInt; + } catch { + throw new Error(`Invalid hex color provided: ${originalHex}`); + } + }); + + return rgb as RGBColor; +} + +/** + * Convert a RGB color set to a hex color. + */ +export function rgbArrayToHex(rgb: RGBColor): string { + return `#${rgb + .map((channel) => { + const component = channel.toString(16); + if (component.length === 1) return `0${component}`; + return component; + }) + .join('')}`; +} + +export function getColor(percentage: number, start: RGBColor, end: RGBColor) { + const rgb = end.map((channel, index) => { + return Math.round(channel + percentage * (start[index] - channel)); + }); + + return rgbArrayToHex(rgb as RGBColor); +} + +// Utility constants and helper functions +export function rgbToLinear(rgb: RGBColor): [number, number, number] { + return rgb.map((v) => { + const scaled = v / 255; + return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4; + }) as [number, number, number]; +} + +export function linearToRgb(linear: [number, number, number]): RGBColor { + return linear.map((v) => { + const scaled = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; + return Math.round(Math.max(0, Math.min(1, scaled)) * 255); + }) as RGBColor; +} + +export function rgbToOklab(rgb: RGBColor): OKLABColor { + const [r, g, b] = rgbToLinear(rgb); + + const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; + const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; + const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; + + const lRoot = Math.cbrt(l); + const mRoot = Math.cbrt(m); + const sRoot = Math.cbrt(s); + + return { + L: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot, + A: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot, + B: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot, + }; +} + +export function oklabToRgb(oklab: OKLABColor): RGBColor { + const { L, A, B } = oklab; + + const lRoot = L + 0.3963377774 * A + 0.2158037573 * B; + const mRoot = L - 0.1055613458 * A - 0.0638541728 * B; + const sRoot = L - 0.0894841775 * A - 1.291485548 * B; + + const l = lRoot ** 3; + const m = mRoot ** 3; + const s = sRoot ** 3; + + const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; + const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; + const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; + + return linearToRgb([r, g, b]); +} + +export function oklabToOklch(oklab: OKLABColor): OKLCHColor { + const { L, A, B } = oklab; + const C = Math.sqrt(A ** 2 + B ** 2); + const H = (Math.atan2(B, A) * 180) / Math.PI; + return { L, C, H: H < 0 ? H + 360 : H }; +} + +export function oklchToOklab(oklch: OKLCHColor): OKLABColor { + const { L, C, H } = oklch; + const rad = (H * Math.PI) / 180; + return { + L, + A: C * Math.cos(rad), + B: C * Math.sin(rad), + }; +} + +export function rgbToOklch(rgb: RGBColor): OKLCHColor { + return oklabToOklch(rgbToOklab(rgb)); +} + +export function oklchToRgb(oklch: OKLCHColor): RGBColor { + return oklabToRgb(oklchToOklab(oklch)); +} + +export function rgbToXyz(rgb: RGBColor): [number, number, number] { + const [r, g, b] = rgbToLinear(rgb); + return [ + (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100, + (r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100, + (r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100, + ]; +} + +export function xyzToLab65(xyz: [number, number, number]): { + L: number; + A: number; + B: number; +} { + const [x, y, z] = xyz.map((v, i) => { + const scaled = v / D65[i]; + return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116; + }); + + return { + L: 116 * y - 16, + A: 500 * (x - y), + B: 200 * (y - z), + }; +} + +export function rgbTolab65(rgb: RGBColor): { L: number; A: number; B: number } { + return xyzToLab65(rgbToXyz(rgb)); +} + +/* + Delta Phi Star perceptual lightness contrast by Andrew Somers: + https://github.com/Myndex/deltaphistar +*/ +export const PHI = 0.5 + Math.sqrt(1.25); + +export function dpsContrast(a: RGBColor, b: RGBColor) { + const dps = Math.abs(rgbTolab65(a).L ** PHI - rgbTolab65(b).L ** PHI); + const contrast = dps ** (1 / PHI) * Math.SQRT2 - 40; + return contrast < 7.5 ? 0 : contrast; +} + +export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) { + const bg = hexToRgbArray(background); + + const best: { color?: RGBColor; contrast: number } = { + color: undefined, + contrast: 0, + }; + for (const color of foreground) { + const c = hexToRgbArray(color); + + const contrast = dpsContrast(c, bg); + if (contrast > best.contrast) { + best.color = c; + best.contrast = contrast; + } + } + + return best.color ? rgbArrayToHex(best.color) : foreground[0]; +} diff --git a/packages/colors/tsconfig.json b/packages/colors/tsconfig.json new file mode 100644 index 0000000000..85cae73fd9 --- /dev/null +++ b/packages/colors/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": false, + "declaration": true, + "outDir": "dist", + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react", + "incremental": true, + "types": [ + "bun-types" // add Bun global + ] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules"] +} From a378f8412ab66d14f64ea8fd74be9adb9524946d Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Wed, 19 Feb 2025 16:22:55 +0100 Subject: [PATCH 2/5] replace with @gitbook/colors --- bun.lock | 4 +- packages/colors/.gitignore | 1 + packages/colors/package.json | 1 + packages/gitbook/package.json | 1 + .../~gitbook/ogimage/[pageId]/route.tsx | 2 +- .../src/components/Ads/AdCoverRendering.tsx | 2 +- .../RootLayout/CustomizationRootLayout.tsx | 12 +- packages/gitbook/src/lib/colors.ts | 402 ------------------ packages/gitbook/tailwind.config.ts | 2 +- 9 files changed, 13 insertions(+), 414 deletions(-) create mode 100644 packages/colors/.gitignore delete mode 100644 packages/gitbook/src/lib/colors.ts diff --git a/bun.lock b/bun.lock index d944df9614..0f48fcf4b8 100644 --- a/bun.lock +++ b/bun.lock @@ -28,9 +28,6 @@ "devDependencies": { "typescript": "^5.5.3", }, - "peerDependencies": { - "react": "*", - }, }, "packages/emoji-codepoints": { "name": "@gitbook/emoji-codepoints", @@ -45,6 +42,7 @@ "dependencies": { "@gitbook/api": "^0.93.0", "@gitbook/cache-do": "workspace:*", + "@gitbook/colors": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", "@gitbook/icons": "workspace:*", "@gitbook/openapi-parser": "workspace:*", diff --git a/packages/colors/.gitignore b/packages/colors/.gitignore new file mode 100644 index 0000000000..849ddff3b7 --- /dev/null +++ b/packages/colors/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/packages/colors/package.json b/packages/colors/package.json index 8b46045b84..77453a2a7f 100644 --- a/packages/colors/package.json +++ b/packages/colors/package.json @@ -18,6 +18,7 @@ "dev": "tsc -w" }, "files": [ + "dist", "src", "README.md", "CHANGELOG.md" diff --git a/packages/gitbook/package.json b/packages/gitbook/package.json index f7f5dc0994..ad4dc7010f 100644 --- a/packages/gitbook/package.json +++ b/packages/gitbook/package.json @@ -19,6 +19,7 @@ "dependencies": { "@gitbook/api": "^0.93.0", "@gitbook/cache-do": "workspace:*", + "@gitbook/colors": "workspace:*", "@gitbook/emoji-codepoints": "workspace:*", "@gitbook/icons": "workspace:*", "@gitbook/openapi-parser": "workspace:*", diff --git a/packages/gitbook/src/app/middleware/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx b/packages/gitbook/src/app/middleware/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx index 7840cef35f..196ea3ee92 100644 --- a/packages/gitbook/src/app/middleware/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx +++ b/packages/gitbook/src/app/middleware/(site)/(core)/~gitbook/ogimage/[pageId]/route.tsx @@ -1,11 +1,11 @@ import { CustomizationHeaderPreset } from '@gitbook/api'; +import { colorContrast } from '@gitbook/colors'; import { redirect } from 'next/navigation'; import { ImageResponse } from 'next/og'; import { NextRequest } from 'next/server'; import React from 'react'; import { googleFontsMap } from '@/fonts'; -import { colorContrast } from '@/lib/colors'; import { getAbsoluteHref } from '@/lib/links'; import { filterOutNullable } from '@/lib/typescript'; import { getContentTitle } from '@/lib/utils'; diff --git a/packages/gitbook/src/components/Ads/AdCoverRendering.tsx b/packages/gitbook/src/components/Ads/AdCoverRendering.tsx index 543193be9b..b6405165ba 100644 --- a/packages/gitbook/src/components/Ads/AdCoverRendering.tsx +++ b/packages/gitbook/src/components/Ads/AdCoverRendering.tsx @@ -1,7 +1,7 @@ import { SiteInsightsAd } from '@gitbook/api'; +import { hexToRgba } from '@gitbook/colors'; import * as React from 'react'; -import { hexToRgba } from '@/lib/colors'; import { getResizedImageURL } from '@/lib/images'; import { tcls } from '@/lib/tailwind'; diff --git a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx index 1459fbde6b..8eed4f5863 100644 --- a/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx +++ b/packages/gitbook/src/components/RootLayout/CustomizationRootLayout.tsx @@ -9,18 +9,18 @@ import { type CustomizationTint, type SiteCustomizationSettings, } from '@gitbook/api'; -import { IconsProvider, IconStyle } from '@gitbook/icons'; - -import { fontNotoColorEmoji, fonts, ibmPlexMono } from '@/fonts'; -import { getSpaceLanguage } from '@/intl/server'; -import { getStaticFileURL } from '@/lib/assets'; import { colorContrast, colorScale, type ColorScaleOptions, DEFAULT_TINT_COLOR, hexToRgb, -} from '@/lib/colors'; +} from '@gitbook/colors'; +import { IconsProvider, IconStyle } from '@gitbook/icons'; + +import { fontNotoColorEmoji, fonts, ibmPlexMono } from '@/fonts'; +import { getSpaceLanguage } from '@/intl/server'; +import { getStaticFileURL } from '@/lib/assets'; import { tcls } from '@/lib/tailwind'; import { ClientContexts } from './ClientContexts'; diff --git a/packages/gitbook/src/lib/colors.ts b/packages/gitbook/src/lib/colors.ts deleted file mode 100644 index b289e73a3b..0000000000 --- a/packages/gitbook/src/lib/colors.ts +++ /dev/null @@ -1,402 +0,0 @@ -type ColorShades = { - [key: string]: string; -}; - -type RGBColor = [number, number, number]; -type OKLABColor = { L: number; A: number; B: number }; -type OKLCHColor = { L: number; C: number; H: number }; - -export const DARK_BASE = '#1d1d1d'; -export const LIGHT_BASE = '#ffffff'; -export const DEFAULT_TINT_COLOR = '#787878'; -const D65 = [95.047, 100.0, 108.883]; // Reference white (D65) - -export enum ColorCategory { - backgrounds = 'backgrounds', - components = 'components', - borders = 'borders', - accents = 'accents', - text = 'text', -} - -type ColorSubScale = { - [key: string]: number; -}; - -/** - * Main color scale object. - * - * Each `ColorCategory` can be in/excluded in Tailwind's utility classes generation. - * Each subitem maps a semantic name within that category to a step in the scale. - */ -export const scale: Record = { - [ColorCategory.backgrounds]: { - /** Base background */ - base: 1, - /** Accent background */ - subtle: 2, - }, - [ColorCategory.components]: { - /** Component background */ - DEFAULT: 3, - /** Component hover background */ - hover: 4, - /** Component active background */ - active: 5, - }, - [ColorCategory.borders]: { - /** Subtle borders, separators */ - subtle: 6, - /** Element border, focus rings */ - DEFAULT: 7, - /** Element hover border */ - hover: 8, - }, - [ColorCategory.accents]: { - /** Solid backgrounds */ - solid: 9, - /** Hovered solid backgrounds */ - 'solid-hover': 10, - }, - [ColorCategory.text]: { - /** Very low-contrast text - * Caution: this contrast does not meet accessiblity guidelines. - * Always check if you need to include a mitigating contrast-more style for users who need it. */ - subtle: 9, - /** Low-contrast text */ - DEFAULT: 11, - /** High-contrast text */ - strong: 12, - }, -}; - -/** - * The mix of foreground and background for every step in a colour scale. - * 0: 100% of the background color's luminosity, white in light mode - * 1: 100% of the foreground color's luminosity, black in light mode - */ -export const colorMixMapping = { - // bgs |components |borders |solid |text - light: [0, 0.02, 0.03, 0.05, 0.07, 0.1, 0.15, 0.2, 0.5, 0.55, 0.6, 1], - dark: [0, 0.03, 0.08, 0.1, 0.13, 0.15, 0.2, 0.25, 0.5, 0.55, 0.75, 1], -}; - -/** - * Convert a hex color to an RGB color. - */ -export function hexToRgb(hex: string): string { - const [r, g, b] = hexToRgbArray(hex); - // Return the RGB values separated by spaces - return `${r} ${g} ${b}`; -} - -/** - * Convert a hex color to a RGBA color. - */ -export function hexToRgba(hex: string, alpha: number): string { - const [r, g, b] = hexToRgbArray(hex); - // Return the RGBA values separated by spaces - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -} - -/** - * Generate Tailwind-compatible shades from a single color - * @param {string} hex The hex code to generate shades from - * @param {boolean} halfShades Generate additional shades, e.g. at 150 - * @returns {{[key: number]: string}} - */ -export function shadesOfColor(hex: string, halfShades = false) { - const baseColor = hex; - - const shades = [ - 50, - 100, - 200, - 300, - 400, - 500, - 600, - 700, - 800, - 900, - ...(halfShades ? [150, 250, 350, 450, 550, 650, 750, 850] : []), - ].sort(); - - const result: ColorShades = {}; - - for (const shade of shades) { - const key = shade.toString(); - - if (shade === 500) { - result[key] = hex; - continue; - } - - let shadeIndex = shade; - const isDarkShade = shadeIndex > 500; - if (isDarkShade) { - shadeIndex -= 500; - } - - const percentage = shadeIndex / 500; - const startColor = isDarkShade ? DARK_BASE : baseColor; - const endColor = isDarkShade ? baseColor : LIGHT_BASE; - - result[key] = getColor(percentage, hexToRgbArray(startColor), hexToRgbArray(endColor)); - } - - return result; -} - -export type ColorScaleOptions = { - /** If set to `true`, inverts the scale (so 1 is black instead of white) and uses `colorMixMapping.dark` with different mix ratios per step. */ - darkMode?: boolean; - - /** Define a custom background color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */ - background?: string; - - /** Define a custom foreground color to use. If left undefined, the global `light`/`dark` values (in `colors.ts`) will be used. */ - foreground?: string; - - mix?: { - /** If set to a hex code, this color will be additionally mixed into the generated scale according to `mix.ratio`. */ - color: string; - - /** Define a custom mix ratio to mix the `mix` color with. If left undefined, the default ratio will be used. */ - ratio: number; - }; -}; - -/** - * Generate a [Radix-like](https://www.radix-ui.com/colors/docs/palette-composition/understanding-the-scale) colour scale based of a hex colour. - * @param {string} hex The hex code to generate shades from - * @param {object} options - */ -export function colorScale( - hex: string, - { - darkMode = false, - background = darkMode ? DARK_BASE : LIGHT_BASE, - foreground = darkMode ? LIGHT_BASE : DARK_BASE, - mix, - }: ColorScaleOptions = {}, -) { - const baseColor = rgbToOklch(hexToRgbArray(hex)); - const mixColor = mix?.color ? rgbToOklch(hexToRgbArray(mix.color)) : null; - const foregroundColor = rgbToOklch(hexToRgbArray(foreground)); - const backgroundColor = rgbToOklch(hexToRgbArray(background)); - - if (mixColor && mix?.ratio && mix.ratio > 0) { - // If defined, we mix in a (tiny) bit of the mix color with the base color. - baseColor.L = mixColor.L * mix.ratio + baseColor.L * (1 - mix.ratio); - baseColor.C = mixColor.C * mix.ratio + baseColor.C * (1 - mix.ratio); - baseColor.H = mix.color === DEFAULT_TINT_COLOR ? baseColor.H : mixColor.H; - } - - const mapping = darkMode ? colorMixMapping.dark : colorMixMapping.light; - - const result = []; - - for (let index = 0; index < mapping.length; index++) { - const targetL = - foregroundColor.L * mapping[index] + backgroundColor.L * (1 - mapping[index]); - - if (index === 8 && !mix && Math.abs(baseColor.L - targetL) < 0.2) { - // Original colour is close enough to target, so let's use the original colour as step 9. - result.push(hex); - continue; - } - - const chromaRatio = index < 8 ? (index + 1) * 0.05 : 1; - - const shade = { - L: targetL, // Blend lightness - C: baseColor.C * chromaRatio, - H: baseColor.H, // Maintain the hue from the base color - }; - - const newHex = rgbArrayToHex(oklchToRgb(shade)); - - result.push(newHex); - } - - return result; -} - -/** - * Convert a hex color to an RGB color set. - */ -function hexToRgbArray(hex: string): RGBColor { - const originalHex = hex; - - let value = hex.replace('#', ''); - if (hex.length === 3) value = value + value; - - const r = value.substring(0, 2); - const g = value.substring(2, 4); - const b = value.substring(4, 6); - - const rgb = [r, g, b].map((channel) => { - try { - const channelInt = Number.parseInt(channel, 16); - if (channelInt < 0 || channelInt > 255) throw new Error(); - return channelInt; - } catch { - throw new Error(`Invalid hex color provided: ${originalHex}`); - } - }); - - return rgb as RGBColor; -} - -/** - * Convert a RGB color set to a hex color. - */ -function rgbArrayToHex(rgb: RGBColor): string { - return `#${rgb - .map((channel) => { - const component = channel.toString(16); - if (component.length === 1) return `0${component}`; - return component; - }) - .join('')}`; -} - -function getColor(percentage: number, start: RGBColor, end: RGBColor) { - const rgb = end.map((channel, index) => { - return Math.round(channel + percentage * (start[index] - channel)); - }); - - return rgbArrayToHex(rgb as RGBColor); -} - -// Utility constants and helper functions -function rgbToLinear(rgb: RGBColor): [number, number, number] { - return rgb.map((v) => { - const scaled = v / 255; - return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4; - }) as [number, number, number]; -} - -function linearToRgb(linear: [number, number, number]): RGBColor { - return linear.map((v) => { - const scaled = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055; - return Math.round(Math.max(0, Math.min(1, scaled)) * 255); - }) as RGBColor; -} - -function rgbToOklab(rgb: RGBColor): OKLABColor { - const [r, g, b] = rgbToLinear(rgb); - - const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b; - const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b; - const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b; - - const lRoot = Math.cbrt(l); - const mRoot = Math.cbrt(m); - const sRoot = Math.cbrt(s); - - return { - L: 0.2104542553 * lRoot + 0.793617785 * mRoot - 0.0040720468 * sRoot, - A: 1.9779984951 * lRoot - 2.428592205 * mRoot + 0.4505937099 * sRoot, - B: 0.0259040371 * lRoot + 0.7827717662 * mRoot - 0.808675766 * sRoot, - }; -} - -function oklabToRgb(oklab: OKLABColor): RGBColor { - const { L, A, B } = oklab; - - const lRoot = L + 0.3963377774 * A + 0.2158037573 * B; - const mRoot = L - 0.1055613458 * A - 0.0638541728 * B; - const sRoot = L - 0.0894841775 * A - 1.291485548 * B; - - const l = lRoot ** 3; - const m = mRoot ** 3; - const s = sRoot ** 3; - - const r = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s; - const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s; - const b = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s; - - return linearToRgb([r, g, b]); -} - -function oklabToOklch(oklab: OKLABColor): OKLCHColor { - const { L, A, B } = oklab; - const C = Math.sqrt(A ** 2 + B ** 2); - const H = (Math.atan2(B, A) * 180) / Math.PI; - return { L, C, H: H < 0 ? H + 360 : H }; -} - -function oklchToOklab(oklch: OKLCHColor): OKLABColor { - const { L, C, H } = oklch; - const rad = (H * Math.PI) / 180; - return { - L, - A: C * Math.cos(rad), - B: C * Math.sin(rad), - }; -} - -function rgbToOklch(rgb: RGBColor): OKLCHColor { - return oklabToOklch(rgbToOklab(rgb)); -} - -function oklchToRgb(oklch: OKLCHColor): RGBColor { - return oklabToRgb(oklchToOklab(oklch)); -} - -function rgbToXyz(rgb: RGBColor): [number, number, number] { - const [r, g, b] = rgbToLinear(rgb); - return [ - (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100, - (r * 0.2126729 + g * 0.7151522 + b * 0.072175) * 100, - (r * 0.0193339 + g * 0.119192 + b * 0.9503041) * 100, - ]; -} - -function xyzToLab65(xyz: [number, number, number]): { L: number; A: number; B: number } { - const [x, y, z] = xyz.map((v, i) => { - const scaled = v / D65[i]; - return scaled > 0.008856 ? Math.cbrt(scaled) : 7.787 * scaled + 16 / 116; - }); - - return { - L: 116 * y - 16, - A: 500 * (x - y), - B: 200 * (y - z), - }; -} - -function rgbTolab65(rgb: RGBColor): { L: number; A: number; B: number } { - return xyzToLab65(rgbToXyz(rgb)); -} - -/* - Delta Phi Star perceptual lightness contrast by Andrew Somers: - https://github.com/Myndex/deltaphistar -*/ -const PHI = 0.5 + Math.sqrt(1.25); - -export function dpsContrast(a: RGBColor, b: RGBColor) { - const dps = Math.abs(rgbTolab65(a).L ** PHI - rgbTolab65(b).L ** PHI); - const contrast = dps ** (1 / PHI) * Math.SQRT2 - 40; - return contrast < 7.5 ? 0 : contrast; -} - -export function colorContrast(background: string, foreground: string[] = [LIGHT_BASE, DARK_BASE]) { - const bg = hexToRgbArray(background); - - const best: { color?: RGBColor; contrast: number } = { color: undefined, contrast: 0 }; - for (const color of foreground) { - const c = hexToRgbArray(color); - - const contrast = dpsContrast(c, bg); - if (contrast > best.contrast) { - best.color = c; - best.contrast = contrast; - } - } - - return best.color ? rgbArrayToHex(best.color) : foreground[0]; -} diff --git a/packages/gitbook/tailwind.config.ts b/packages/gitbook/tailwind.config.ts index bff7348560..1a56691f3a 100644 --- a/packages/gitbook/tailwind.config.ts +++ b/packages/gitbook/tailwind.config.ts @@ -3,7 +3,7 @@ import typography from '@tailwindcss/typography'; import type { Config } from 'tailwindcss'; import plugin from 'tailwindcss/plugin'; -import { ColorCategory, hexToRgb, scale, shadesOfColor } from './src/lib/colors'; +import { ColorCategory, hexToRgb, scale, shadesOfColor } from '@gitbook/colors'; export const shades = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900]; export const opacities = [0, 4, 8, 12, 16, 24, 40, 64, 72, 88, 96, 100]; From 3cd9169c441030ec57feadb23faf49322df0d262 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra Date: Wed, 19 Feb 2025 16:24:04 +0100 Subject: [PATCH 3/5] changeset --- .changeset/breezy-seals-yell.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/breezy-seals-yell.md diff --git a/.changeset/breezy-seals-yell.md b/.changeset/breezy-seals-yell.md new file mode 100644 index 0000000000..79235382af --- /dev/null +++ b/.changeset/breezy-seals-yell.md @@ -0,0 +1,5 @@ +--- +'@gitbook/colors': minor +--- + +Initial release From 6fc1bceba587a8cd5d7fe869a5210a5d51550a5a Mon Sep 17 00:00:00 2001 From: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com> Date: Wed, 19 Feb 2025 17:05:33 +0100 Subject: [PATCH 4/5] Apply suggestions from code review Co-authored-by: Zeno Kapitein --- packages/colors/src/colors.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/colors/src/colors.ts b/packages/colors/src/colors.ts index 81061cadef..a4913f1b8b 100644 --- a/packages/colors/src/colors.ts +++ b/packages/colors/src/colors.ts @@ -4,12 +4,12 @@ export const DEFAULT_PRIMARY_COLOR = '#346DDB'; /** - * + * The darkest color that exists in GitBook, used as the relative minimum of every generated color scale. */ export const DARK_BASE = '#1D1D1D'; /** - * + * The lightest color that exists in GitBook, used as the relative maximum of every generated color scale. */ export const LIGHT_BASE = '#FFFFFF'; @@ -19,21 +19,21 @@ export const LIGHT_BASE = '#FFFFFF'; export const DEFAULT_TINT_COLOR = '#787878'; /** - * + * Used for informational messages and neutral alerts. */ export const DEFAULT_HINT_INFO_COLOR = '#787878'; /** - * + * Used for showing important information or non-critical warnings. */ export const DEFAULT_HINT_WARNING_COLOR = '#FE9A00'; /** - * + * Used for destructive actions or raising attention to critical information. */ export const DEFAULT_HINT_DANGER_COLOR = '#FB2C36'; /** - * + * Used for showing positive actions or achievements. */ export const DEFAULT_HINT_SUCCESS_COLOR = '#00C950'; From e04547306104be4b711012f5c65e627bc750cff7 Mon Sep 17 00:00:00 2001 From: Valentino Hudhra <2587839+valentin0h@users.noreply.github.com> Date: Wed, 19 Feb 2025 17:07:32 +0100 Subject: [PATCH 5/5] Update packages/colors/src/colors.ts Co-authored-by: Zeno Kapitein --- packages/colors/src/colors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/colors/src/colors.ts b/packages/colors/src/colors.ts index a4913f1b8b..7562febf63 100644 --- a/packages/colors/src/colors.ts +++ b/packages/colors/src/colors.ts @@ -14,7 +14,7 @@ export const DARK_BASE = '#1D1D1D'; export const LIGHT_BASE = '#FFFFFF'; /** - * + * Used as the basis of all UI elements that are not colored by the primary color. Neutral gray by default, overridden by site customization. */ export const DEFAULT_TINT_COLOR = '#787878';