From d672f78734869f546bc2ae52647f3ae381b74c21 Mon Sep 17 00:00:00 2001 From: Andrew Jiang Date: Fri, 3 May 2024 19:58:44 -0400 Subject: [PATCH] improvement: incorporate radix color scaling (#794) --- packages/ui/app/.storybook/variables.css | 20 +- packages/ui/app/package.json | 2 + .../endpoints/EndpointContentLeft.tsx | 2 +- .../api-page/examples/CodeSnippetExample.tsx | 2 +- .../api-page/types/object/ObjectProperty.tsx | 2 +- .../src/api-playground/PlaygroundButton.tsx | 2 +- .../PlaygroundWebSocketSessionForm.tsx | 2 +- packages/ui/app/src/commons/withStream.tsx | 4 +- packages/ui/app/src/commons/withWss.tsx | 4 +- .../ui/app/src/components/FernAudioPlayer.tsx | 2 +- .../ui/app/src/components/FernCheckbox.css | 2 +- packages/ui/app/src/components/FernTabs.tsx | 2 +- packages/ui/app/src/docs/BgImageGradient.tsx | 4 +- packages/ui/app/src/docs/HeaderTabs.tsx | 2 +- packages/ui/app/src/mdx/components/Badge.tsx | 2 +- .../ui/app/src/mdx/components/CodeGroup.tsx | 4 +- packages/ui/app/src/mdx/components/Tabs.tsx | 2 +- packages/ui/app/src/next-app/globals.scss | 10 +- .../src/next-app/utils/getColorVariables.ts | 257 +++----- .../ui/app/src/sidebar/SidebarTabButton.tsx | 2 +- .../CodeBlockWithClipboardButton.tsx | 2 +- .../FernSyntaxHighlighter.css | 16 +- .../ui/app/src/util/generateRadixColors.ts | 615 ++++++++++++++++++ packages/ui/tailwind.config.js | 62 +- pnpm-lock.yaml | 14 + 25 files changed, 782 insertions(+), 256 deletions(-) create mode 100644 packages/ui/app/src/util/generateRadixColors.ts diff --git a/packages/ui/app/.storybook/variables.css b/packages/ui/app/.storybook/variables.css index 50db7f02da..2db7a47e7f 100644 --- a/packages/ui/app/.storybook/variables.css +++ b/packages/ui/app/.storybook/variables.css @@ -24,12 +24,12 @@ --grayscale-a10: var(--sand-a10); --grayscale-a11: var(--sand-a11); --grayscale-a12: var(--sand-a12); - --accent-primary: 65, 131, 38; - --accent-primary-aa: 65, 131, 38; - --accent-primary-aaa: 49, 99, 29; - --accent-primary-tinted: 55, 111, 32; + --accent: 65, 131, 38; + --accent-aa: 65, 131, 38; + --accent-aaa: 49, 99, 29; + --accent-tinted: 55, 111, 32; --background: 255, 255, 255; - --accent-primary-contrast: 255, 255, 255; + --accent-contrast: 255, 255, 255; --card-background: rgba(255, 255, 255, 70%); --sidebar-background: transparent; --header-background: transparent; @@ -58,12 +58,12 @@ --grayscale-a10: var(--olive-a10); --grayscale-a11: var(--olive-a11); --grayscale-a12: var(--olive-a12); - --accent-primary: 173, 255, 140; - --accent-primary-aa: 173, 255, 140; - --accent-primary-aaa: 173, 255, 140; - --accent-primary-tinted: 191, 255, 165; + --accent: 173, 255, 140; + --accent-aa: 173, 255, 140; + --accent-aaa: 173, 255, 140; + --accent-tinted: 191, 255, 165; --background: 0, 0, 0; - --accent-primary-contrast: 0, 0, 0; + --accent-contrast: 0, 0, 0; --card-background: var(--grayscale-a2); --sidebar-background: transparent; --header-background: transparent; diff --git a/packages/ui/app/package.json b/packages/ui/app/package.json index 7736851224..5edc6fa089 100644 --- a/packages/ui/app/package.json +++ b/packages/ui/app/package.json @@ -60,7 +60,9 @@ "@shikijs/transformers": "^1.2.2", "@types/nprogress": "^0.2.3", "algoliasearch": "^4.22.1", + "bezier-easing": "^2.1.0", "clsx": "^2.1.0", + "colorjs.io": "^0.5.0", "estree-util-visit": "^2.0.0", "github-slugger": "^2.0.0", "gray-matter": "^4.0.3", diff --git a/packages/ui/app/src/api-page/endpoints/EndpointContentLeft.tsx b/packages/ui/app/src/api-page/endpoints/EndpointContentLeft.tsx index a20866c6c6..d68689400d 100644 --- a/packages/ui/app/src/api-page/endpoints/EndpointContentLeft.tsx +++ b/packages/ui/app/src/api-page/endpoints/EndpointContentLeft.tsx @@ -148,7 +148,7 @@ const UnmemoizedEndpointContentLeft: React.FC = ({ {requestBody.contentType} diff --git a/packages/ui/app/src/api-page/examples/CodeSnippetExample.tsx b/packages/ui/app/src/api-page/examples/CodeSnippetExample.tsx index d8d8fb94c2..0c481d7983 100644 --- a/packages/ui/app/src/api-page/examples/CodeSnippetExample.tsx +++ b/packages/ui/app/src/api-page/examples/CodeSnippetExample.tsx @@ -78,7 +78,7 @@ const CodeSnippetExampleInternal: FC = ({ copyToClipboardText={useCallback(() => code, [code])} {...props} className={clsx(className, { - "dark bg-card": isDarkCodeEnabled, + "dark bg-card-solid": isDarkCodeEnabled, })} >
diff --git a/packages/ui/app/src/api-playground/PlaygroundButton.tsx b/packages/ui/app/src/api-playground/PlaygroundButton.tsx index 8e85ef088b..b774877252 100644 --- a/packages/ui/app/src/api-playground/PlaygroundButton.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundButton.tsx @@ -16,7 +16,7 @@ export const PlaygroundButton: FC<{ state: PlaygroundSelectionState }> = ({ stat - Customize and run in API Playground + Customize and run in API Playground } > diff --git a/packages/ui/app/src/api-playground/PlaygroundWebSocketSessionForm.tsx b/packages/ui/app/src/api-playground/PlaygroundWebSocketSessionForm.tsx index 637c4a07d0..94fbf2dd72 100644 --- a/packages/ui/app/src/api-playground/PlaygroundWebSocketSessionForm.tsx +++ b/packages/ui/app/src/api-playground/PlaygroundWebSocketSessionForm.tsx @@ -114,7 +114,7 @@ export const PlaygroundWebSocketSessionForm: FC diff --git a/packages/ui/app/src/commons/withStream.tsx b/packages/ui/app/src/commons/withStream.tsx index f26996612a..88b018009a 100644 --- a/packages/ui/app/src/commons/withStream.tsx +++ b/packages/ui/app/src/commons/withStream.tsx @@ -5,8 +5,8 @@ export function StreamTag({ small = false, active = false }: { small?: boolean; return ( : } + icon={isPlaying ? : } variant="filled" intent="primary" rounded diff --git a/packages/ui/app/src/components/FernCheckbox.css b/packages/ui/app/src/components/FernCheckbox.css index 282e275098..9795a38e25 100644 --- a/packages/ui/app/src/components/FernCheckbox.css +++ b/packages/ui/app/src/components/FernCheckbox.css @@ -15,5 +15,5 @@ } .fern-checkbox-indicator { - @apply size-4 bg-accent flex items-center justify-center text-accent-primary-contrast rounded-sm; + @apply size-4 bg-accent flex items-center justify-center text-accent-contrast rounded-sm; } diff --git a/packages/ui/app/src/components/FernTabs.tsx b/packages/ui/app/src/components/FernTabs.tsx index 086c51a598..059a5c51a3 100644 --- a/packages/ui/app/src/components/FernTabs.tsx +++ b/packages/ui/app/src/components/FernTabs.tsx @@ -16,7 +16,7 @@ export const FernTabs: FC = ({ tabs, ...props }) => { {tabs.map(({ title }, idx) => ( -
+
{title}
diff --git a/packages/ui/app/src/docs/BgImageGradient.tsx b/packages/ui/app/src/docs/BgImageGradient.tsx index 729c693378..0c32d19a07 100644 --- a/packages/ui/app/src/docs/BgImageGradient.tsx +++ b/packages/ui/app/src/docs/BgImageGradient.tsx @@ -19,9 +19,9 @@ export const BgImageGradient: FC = ({ className }) => { return (
(
  • diff --git a/packages/ui/app/src/mdx/components/Badge.tsx b/packages/ui/app/src/mdx/components/Badge.tsx index f4a31156d4..918e798002 100644 --- a/packages/ui/app/src/mdx/components/Badge.tsx +++ b/packages/ui/app/src/mdx/components/Badge.tsx @@ -26,7 +26,7 @@ export function Badge({ "text-white dark:text-black": !minimal && intent !== "primary", "dark:ring-white/30 ring-black/30": outlined, "bg-intent-default": intent === "none", - "bg-accent-primary text-accent-primary-contrast": intent === "primary" && !minimal, + "bg-accent text-accent-contrast": intent === "primary" && !minimal, "bg-intent-success": intent === "success" && !minimal, "bg-intent-warning": intent === "warning" && !minimal, "bg-intent-danger": intent === "danger" && !minimal, diff --git a/packages/ui/app/src/mdx/components/CodeGroup.tsx b/packages/ui/app/src/mdx/components/CodeGroup.tsx index 7f279584d7..1557e9c4ef 100644 --- a/packages/ui/app/src/mdx/components/CodeGroup.tsx +++ b/packages/ui/app/src/mdx/components/CodeGroup.tsx @@ -23,7 +23,7 @@ export const CodeGroup: React.FC> = ({ const containerClass = clsx( "after:ring-default bg-card relative mt-4 first:mt-0 mb-6 flex w-full min-w-0 max-w-full flex-col rounded-lg shadow-sm after:pointer-events-none after:absolute after:inset-0 after:rounded-[inherit] after:ring-1 after:ring-inset after:content-['']", { - "dark bg-card": isDarkCodeEnabled, + "dark bg-card-solid": isDarkCodeEnabled, }, ); @@ -61,7 +61,7 @@ export const CodeGroup: React.FC> = ({ {item.title ?? `Untitled ${idx + 1}`} diff --git a/packages/ui/app/src/mdx/components/Tabs.tsx b/packages/ui/app/src/mdx/components/Tabs.tsx index bffcadc7bd..c257d609e4 100644 --- a/packages/ui/app/src/mdx/components/Tabs.tsx +++ b/packages/ui/app/src/mdx/components/Tabs.tsx @@ -35,7 +35,7 @@ export const TabGroup: FC = ({ tabs, toc: parentToc = true }) => return (
    diff --git a/packages/ui/app/src/next-app/globals.scss b/packages/ui/app/src/next-app/globals.scss index 0a3d818b68..9583e31f5f 100644 --- a/packages/ui/app/src/next-app/globals.scss +++ b/packages/ui/app/src/next-app/globals.scss @@ -38,7 +38,7 @@ /* html.dark { - background-color: var(--accent-primary); + background-color: var(--accent); } */ *, @@ -707,7 +707,7 @@ &.interactive { @apply cursor-pointer shadow-card; - @apply hover:shadow-card-elevated hover:border-accent-primary transition-all; + @apply hover:shadow-card-elevated hover:border-accent transition-all; @apply active:shadow-card-elevated active:border-primary; &.active { @@ -767,7 +767,7 @@ } .fern-numeric-input-group:focus-within .fern-numeric-input-step { - @apply ring-accent-primary hover:bg-tag-primary t-accent hover:t-accent border-primary; + @apply ring-accent hover:bg-tag-primary t-accent hover:t-accent border-primary; } .fern-numeric-input-group .fern-numeric-input-step:first-child { @@ -780,7 +780,7 @@ .fern-input, .fern-textarea { - @apply caret-accent-primary py-2 px-2.5 text-sm; + @apply caret-accent py-2 px-2.5 text-sm; @apply max-sm:text-base; } @@ -795,7 +795,7 @@ } .fern-mdx-link { - @apply hover:t-accent underline underline-offset-4 decoration-1 hover:decoration-2 decoration-accent-primary-aa; + @apply hover:t-accent underline underline-offset-4 decoration-1 hover:decoration-2 decoration-accent-aa; @apply font-semibold; h1 &, diff --git a/packages/ui/app/src/next-app/utils/getColorVariables.ts b/packages/ui/app/src/next-app/utils/getColorVariables.ts index 84989d4218..e5861db4e7 100644 --- a/packages/ui/app/src/next-app/utils/getColorVariables.ts +++ b/packages/ui/app/src/next-app/utils/getColorVariables.ts @@ -1,7 +1,12 @@ import { DocsV1Read } from "@fern-api/fdr-sdk"; import { ColorsConfig } from "@fern-ui/fdr-utils"; -import { gray, mauve, olive, sage, sand, slate } from "@radix-ui/colors"; import tinycolor from "tinycolor2"; +import { + darkGrayColors, + generateRadixColors, + getClosestGrayScale, + lightGrayColors, +} from "../../util/generateRadixColors"; interface ColorConfig { dark: DocsV1Read.RgbaColor; @@ -39,14 +44,15 @@ const DEFAULT_COLORS: { }; export const CSS_VARIABLES = { - ACCENT_PRIMARY: "--accent-primary", - ACCENT_PRIMARY_AA: "--accent-primary-aa", - ACCENT_PRIMARY_AAA: "--accent-primary-aaa", - ACCENT_PRIMARY_TINTED: "--accent-primary-tinted", // for hover state + ACCENT_PRIMARY: "--accent", + ACCENT_PRIMARY_AA: "--accent-aa", + ACCENT_PRIMARY_AAA: "--accent-aaa", + ACCENT_PRIMARY_TINTED: "--accent-tinted", // for hover state BACKGROUND: "--background", // contrast colors are useful for rendering text on top of where accent is the background color - ACCENT_PRIMARY_CONTRAST: "--accent-primary-contrast", + ACCENT_PRIMARY_CONTRAST: "--accent-contrast", CARD_BACKGROUND: "--card-background", + CARD_BACKGROUND_SOLID: "--card-background-solid", SIDEBAR_BACKGROUND: "--sidebar-background", HEADER_BACKGROUND: "--header-background", BORDER: "--border", @@ -63,30 +69,20 @@ export const CSS_VARIABLES = { GRAYSCALE_10: "--grayscale-a10", GRAYSCALE_11: "--grayscale-a11", GRAYSCALE_12: "--grayscale-a12", - GRAYSCALE_1_LIGHT: "--grayscale-light-1", - GRAYSCALE_2_LIGHT: "--grayscale-light-2", - GRAYSCALE_3_LIGHT: "--grayscale-light-3", - GRAYSCALE_4_LIGHT: "--grayscale-light-4", - GRAYSCALE_5_LIGHT: "--grayscale-light-5", - GRAYSCALE_6_LIGHT: "--grayscale-light-6", - GRAYSCALE_7_LIGHT: "--grayscale-light-7", - GRAYSCALE_8_LIGHT: "--grayscale-light-8", - GRAYSCALE_9_LIGHT: "--grayscale-light-9", - GRAYSCALE_10_LIGHT: "--grayscale-light-10", - GRAYSCALE_11_LIGHT: "--grayscale-light-11", - GRAYSCALE_12_LIGHT: "--grayscale-light-12", - GRAYSCALE_1_DARK: "--grayscale-dark-1", - GRAYSCALE_2_DARK: "--grayscale-dark-2", - GRAYSCALE_3_DARK: "--grayscale-dark-3", - GRAYSCALE_4_DARK: "--grayscale-dark-4", - GRAYSCALE_5_DARK: "--grayscale-dark-5", - GRAYSCALE_6_DARK: "--grayscale-dark-6", - GRAYSCALE_7_DARK: "--grayscale-dark-7", - GRAYSCALE_8_DARK: "--grayscale-dark-8", - GRAYSCALE_9_DARK: "--grayscale-dark-9", - GRAYSCALE_10_DARK: "--grayscale-dark-10", - GRAYSCALE_11_DARK: "--grayscale-dark-11", - GRAYSCALE_12_DARK: "--grayscale-dark-12", + ACCENT_1: "--accent-1", + ACCENT_2: "--accent-2", + ACCENT_3: "--accent-3", + ACCENT_4: "--accent-4", + ACCENT_5: "--accent-5", + ACCENT_6: "--accent-6", + ACCENT_7: "--accent-7", + ACCENT_8: "--accent-8", + ACCENT_9: "--accent-9", + ACCENT_10: "--accent-10", + ACCENT_11: "--accent-11", + ACCENT_12: "--accent-12", + ACCENT_SURFACE: "--accent-surface", + GRAY_SURFACE: "--gray-surface", BODY_TEXT: "--body-text", BODY_TEXT_INVERTED: "--body-text-inverted", BACKGROUND_IMAGE: "--docs-background-image", @@ -133,6 +129,10 @@ export function getColorVariables( } { const backgroundColorLight = enforceBackgroundTheme(getColor(colorsV3, "background", "light"), "light").toRgb(); const backgroundColorDark = enforceBackgroundTheme(getColor(colorsV3, "background", "dark"), "dark").toRgb(); + const shouldUseAccentColorLight = + colorsV3.light?.background.type === "gradient" || tinycolor(backgroundColorLight).toHexString() === "#ffffff"; + const shouldUseAccentColorDark = + colorsV3.dark?.background.type === "gradient" || tinycolor(backgroundColorDark).toHexString() === "#000000"; const accentPrimaryLightUi = increaseForegroundContrast( getColor(colorsV3, "accentPrimary", "light"), @@ -145,15 +145,29 @@ export function getColorVariables( "ui", ).toRgb(); - const radixGrayscaleLight = getBestColorScale(tinycolor(accentPrimaryLightUi)); - const radixGrayscaleDark = getBestColorScale(tinycolor(accentPrimaryDarkUi)); - - const accentPrimaryLightContrast = tinycolor(accentPrimaryLightUi).isLight() - ? { r: 0, g: 0, b: 0 } - : { r: 255, g: 255, b: 255 }; - const accentPrimaryDarkContrast = tinycolor(accentPrimaryDarkUi).isLight() - ? { r: 0, g: 0, b: 0 } - : { r: 255, g: 255, b: 255 }; + const radixGrayscaleLight = getClosestGrayScale( + tinycolor(shouldUseAccentColorLight ? accentPrimaryLightUi : backgroundColorLight).toHexString(), + ); + const radixGrayscaleDark = getClosestGrayScale( + tinycolor(shouldUseAccentColorDark ? accentPrimaryDarkUi : backgroundColorDark).toHexString(), + ); + + const radixColorsLight = generateRadixColors({ + appearance: "light", + accent: tinycolor(accentPrimaryLightUi).toHexString(), + gray: lightGrayColors[radixGrayscaleLight][6].toString(), + background: tinycolor(backgroundColorLight).toHexString(), + }); + + const radixColorsDark = generateRadixColors({ + appearance: "dark", + accent: tinycolor(accentPrimaryDarkUi).toHexString(), + gray: darkGrayColors[radixGrayscaleDark][6].toString(), + background: tinycolor(backgroundColorDark).toHexString(), + }); + + const accentPrimaryLightContrast = tinycolor(radixColorsLight.accentContrast).toRgb(); + const accentPrimaryDarkContrast = tinycolor(radixColorsDark.accentContrast).toRgb(); const accentPrimaryLightTinted = ( tinycolor(accentPrimaryLightUi).isLight() @@ -211,6 +225,20 @@ export function getColorVariables( [CSS_VARIABLES.GRAYSCALE_10]: getRadixGrayVar(radixGrayscaleLight, 10), [CSS_VARIABLES.GRAYSCALE_11]: getRadixGrayVar(radixGrayscaleLight, 11), [CSS_VARIABLES.GRAYSCALE_12]: getRadixGrayVar(radixGrayscaleLight, 12), + [CSS_VARIABLES.ACCENT_1]: radixColorsLight.accentScale[0], + [CSS_VARIABLES.ACCENT_2]: radixColorsLight.accentScale[1], + [CSS_VARIABLES.ACCENT_3]: radixColorsLight.accentScale[2], + [CSS_VARIABLES.ACCENT_4]: radixColorsLight.accentScale[3], + [CSS_VARIABLES.ACCENT_5]: radixColorsLight.accentScale[4], + [CSS_VARIABLES.ACCENT_6]: radixColorsLight.accentScale[5], + [CSS_VARIABLES.ACCENT_7]: radixColorsLight.accentScale[6], + [CSS_VARIABLES.ACCENT_8]: radixColorsLight.accentScale[7], + [CSS_VARIABLES.ACCENT_9]: radixColorsLight.accentScale[8], + [CSS_VARIABLES.ACCENT_10]: radixColorsLight.accentScale[9], + [CSS_VARIABLES.ACCENT_11]: radixColorsLight.accentScale[10], + [CSS_VARIABLES.ACCENT_12]: radixColorsLight.accentScale[11], + [CSS_VARIABLES.ACCENT_SURFACE]: radixColorsLight.accentSurfaceWideGamut, + [CSS_VARIABLES.GRAY_SURFACE]: radixColorsLight.graySurfaceWideGamut, [CSS_VARIABLES.ACCENT_PRIMARY]: `${accentPrimaryLightUi.r}, ${accentPrimaryLightUi.g}, ${accentPrimaryLightUi.b}`, [CSS_VARIABLES.ACCENT_PRIMARY_AA]: `${accentPrimaryLightAA.r}, ${accentPrimaryLightAA.g}, ${accentPrimaryLightAA.b}`, @@ -241,6 +269,20 @@ export function getColorVariables( [CSS_VARIABLES.GRAYSCALE_10]: getRadixGrayVar(radixGrayscaleDark, 10), [CSS_VARIABLES.GRAYSCALE_11]: getRadixGrayVar(radixGrayscaleDark, 11), [CSS_VARIABLES.GRAYSCALE_12]: getRadixGrayVar(radixGrayscaleDark, 12), + [CSS_VARIABLES.ACCENT_1]: radixColorsDark.accentScale[0], + [CSS_VARIABLES.ACCENT_2]: radixColorsDark.accentScale[1], + [CSS_VARIABLES.ACCENT_3]: radixColorsDark.accentScale[2], + [CSS_VARIABLES.ACCENT_4]: radixColorsDark.accentScale[3], + [CSS_VARIABLES.ACCENT_5]: radixColorsDark.accentScale[4], + [CSS_VARIABLES.ACCENT_6]: radixColorsDark.accentScale[5], + [CSS_VARIABLES.ACCENT_7]: radixColorsDark.accentScale[6], + [CSS_VARIABLES.ACCENT_8]: radixColorsDark.accentScale[7], + [CSS_VARIABLES.ACCENT_9]: radixColorsDark.accentScale[8], + [CSS_VARIABLES.ACCENT_10]: radixColorsDark.accentScale[9], + [CSS_VARIABLES.ACCENT_11]: radixColorsDark.accentScale[10], + [CSS_VARIABLES.ACCENT_12]: radixColorsDark.accentScale[11], + [CSS_VARIABLES.ACCENT_SURFACE]: radixColorsDark.accentSurfaceWideGamut, + [CSS_VARIABLES.GRAY_SURFACE]: radixColorsDark.graySurfaceWideGamut, [CSS_VARIABLES.ACCENT_PRIMARY]: `${accentPrimaryDarkUi.r}, ${accentPrimaryDarkUi.g}, ${accentPrimaryDarkUi.b}`, [CSS_VARIABLES.ACCENT_PRIMARY_AA]: `${accentPrimaryDarkAA.r}, ${accentPrimaryDarkAA.g}, ${accentPrimaryDarkAA.b}`, @@ -249,11 +291,14 @@ export function getColorVariables( [CSS_VARIABLES.BACKGROUND]: `${backgroundColorDark.r}, ${backgroundColorDark.g}, ${backgroundColorDark.b}`, [CSS_VARIABLES.ACCENT_PRIMARY_CONTRAST]: `${accentPrimaryDarkContrast.r}, ${accentPrimaryDarkContrast.g}, ${accentPrimaryDarkContrast.b}`, [CSS_VARIABLES.CARD_BACKGROUND]: - cardBackgroundDark?.toRgbString() ?? tinycolor(backgroundColorDark).darken(2).toRgbString(), + cardBackgroundDark?.toRgbString() ?? + tinycolor(backgroundColorDark).darken(2).setAlpha(0.5).toRgbString(), + [CSS_VARIABLES.CARD_BACKGROUND_SOLID]: + cardBackgroundDark?.toRgbString() ?? tinycolor(backgroundColorDark).darken(1).toRgbString(), [CSS_VARIABLES.SIDEBAR_BACKGROUND]: sidebarBackgroundDark?.toRgbString() ?? "transparent", [CSS_VARIABLES.HEADER_BACKGROUND]: headerBackgroundDark?.toRgbString() ?? "transparent", - [CSS_VARIABLES.BORDER]: borderDark?.toRgbString() ?? "var(--grayscale-a5)", - [CSS_VARIABLES.BORDER_CONCEALED]: borderDark?.toRgbString() ?? "var(--grayscale-a3)", + [CSS_VARIABLES.BORDER]: borderDark?.toRgbString() ?? "var(--grayscale-a4)", + [CSS_VARIABLES.BORDER_CONCEALED]: borderDark?.toRgbString() ?? "var(--grayscale-a2)", [CSS_VARIABLES.BODY_TEXT]: "255, 255, 255", [CSS_VARIABLES.BODY_TEXT_INVERTED]: "0, 0, 0", [CSS_VARIABLES.BACKGROUND_IMAGE]: getBackgroundImage(colorsV3.dark?.backgroundImage, files), @@ -316,140 +361,12 @@ function getOppositeBrightness(color: tinycolor.Instance | undefined): tinycolor return tinycolor({ h, s, v: 1 - v }); } -function getColorDistance(color1: tinycolor.Instance, color2: tinycolor.Instance): number { - const { r: r1, g: g1, b: b1 } = color1.toRgb(); - const { r: r2, g: g2, b: b2 } = color2.toRgb(); - return Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2); -} - type RadixGray = "gray" | "mauve" | "slate" | "sage" | "olive" | "sand"; function getRadixGrayVar(gray: RadixGray, scale: number): string { return `var(--${gray}-a${scale})`; } -const GRAY_COLORS = { - gray: { - 1: tinycolor(gray.gray1), - 2: tinycolor(gray.gray2), - 3: tinycolor(gray.gray3), - 4: tinycolor(gray.gray4), - 5: tinycolor(gray.gray5), - 6: tinycolor(gray.gray6), - 7: tinycolor(gray.gray7), - 8: tinycolor(gray.gray8), - 9: tinycolor(gray.gray9), - 10: tinycolor(gray.gray10), - 11: tinycolor(gray.gray11), - 12: tinycolor(gray.gray12), - }, - mauve: { - 1: tinycolor(mauve.mauve1), - 2: tinycolor(mauve.mauve2), - 3: tinycolor(mauve.mauve3), - 4: tinycolor(mauve.mauve4), - 5: tinycolor(mauve.mauve5), - 6: tinycolor(mauve.mauve6), - 7: tinycolor(mauve.mauve7), - 8: tinycolor(mauve.mauve8), - 9: tinycolor(mauve.mauve9), - 10: tinycolor(mauve.mauve10), - 11: tinycolor(mauve.mauve11), - 12: tinycolor(mauve.mauve12), - }, - slate: { - 1: tinycolor(slate.slate1), - 2: tinycolor(slate.slate2), - 3: tinycolor(slate.slate3), - 4: tinycolor(slate.slate4), - 5: tinycolor(slate.slate5), - 6: tinycolor(slate.slate6), - 7: tinycolor(slate.slate7), - 8: tinycolor(slate.slate8), - 9: tinycolor(slate.slate9), - 10: tinycolor(slate.slate10), - 11: tinycolor(slate.slate11), - 12: tinycolor(slate.slate12), - }, - sage: { - 1: tinycolor(sage.sage1), - 2: tinycolor(sage.sage2), - 3: tinycolor(sage.sage3), - 4: tinycolor(sage.sage4), - 5: tinycolor(sage.sage5), - 6: tinycolor(sage.sage6), - 7: tinycolor(sage.sage7), - 8: tinycolor(sage.sage8), - 9: tinycolor(sage.sage9), - 10: tinycolor(sage.sage10), - 11: tinycolor(sage.sage11), - 12: tinycolor(sage.sage12), - }, - olive: { - 1: tinycolor(olive.olive1), - 2: tinycolor(olive.olive2), - 3: tinycolor(olive.olive3), - 4: tinycolor(olive.olive4), - 5: tinycolor(olive.olive5), - 6: tinycolor(olive.olive6), - 7: tinycolor(olive.olive7), - 8: tinycolor(olive.olive8), - 9: tinycolor(olive.olive9), - 10: tinycolor(olive.olive10), - 11: tinycolor(olive.olive11), - 12: tinycolor(olive.olive12), - }, - sand: { - 1: tinycolor(sand.sand1), - 2: tinycolor(sand.sand2), - 3: tinycolor(sand.sand3), - 4: tinycolor(sand.sand4), - 5: tinycolor(sand.sand5), - 6: tinycolor(sand.sand6), - 7: tinycolor(sand.sand7), - 8: tinycolor(sand.sand8), - 9: tinycolor(sand.sand9), - 10: tinycolor(sand.sand10), - 11: tinycolor(sand.sand11), - 12: tinycolor(sand.sand12), - }, -} as const; - -// function getClosestGrayColor(color: tinycolor.Instance): RadixGray { -// let closestColor: RadixGray = "gray"; -// let closestDistance = Infinity; -// for (const [gray, sampleColorGroup] of Object.entries(GRAY_COLORS)) { -// for (const [, sampleColor] of Object.entries(sampleColorGroup)) { -// const distance = getColorDistance(color, sampleColor); -// if (distance < closestDistance) { -// closestColor = gray as RadixGray; -// closestDistance = distance; -// } -// } -// } -// return closestColor; -// } - -function getBestColorScale(accentColor: tinycolor.Instance): RadixGray { - let minDistance = Infinity; - let bestColorScale: RadixGray = "gray"; - - for (const [key, scale] of Object.entries(GRAY_COLORS)) { - const scaleColors = Object.values(scale); - const totalDistance = scaleColors.reduce((sum, color) => { - const distance = getColorDistance(accentColor, color); - return sum + distance; - }, 0); - - if (totalDistance < minDistance) { - minDistance = totalDistance; - bestColorScale = key as RadixGray; - } - } - - return bestColorScale; -} - export function getThemeColor(config: DocsV1Read.ThemeConfig): string { const color = config.headerBackground ?? (config.background.type === "solid" ? config.background : undefined); if (color != null) { diff --git a/packages/ui/app/src/sidebar/SidebarTabButton.tsx b/packages/ui/app/src/sidebar/SidebarTabButton.tsx index c6fc5cbd62..8323391594 100644 --- a/packages/ui/app/src/sidebar/SidebarTabButton.tsx +++ b/packages/ui/app/src/sidebar/SidebarTabButton.tsx @@ -25,7 +25,7 @@ const UnmemoizedSidebarTabButton: React.FC = ({ tab, sel >
    -
    +
    diff --git a/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighter.css b/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighter.css index baff35c66a..c6b1563f04 100644 --- a/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighter.css +++ b/packages/ui/app/src/syntax-highlighting/FernSyntaxHighlighter.css @@ -58,24 +58,24 @@ .code-block-line-group:not(.highlight-focus) .code-block-line:not(.highlight) + .code-block-line.highlight, .code-block-line-group:not(.highlight-focus) .code-block-line.highlight:first-child { - box-shadow: inset 0 1px 0 0 rgba(var(--accent-primary), 0.2); + box-shadow: inset 0 1px 0 0 rgba(var(--accent), 0.2); } :is(.dark) .code-block-line-group:not(.highlight-focus) .code-block-line:not(.highlight) + .code-block-line.highlight, :is(.dark) .code-block-line-group:not(.highlight-focus) .code-block-line.highlight:first-child { - box-shadow: inset 0 1px 0 0 rgba(var(--accent-primary), 0.33); + box-shadow: inset 0 1px 0 0 rgba(var(--accent), 0.33); } .code-block-line-group:not(.highlight-focus) .code-block-line.highlight:has(+ .code-block-line:not(.highlight)), .code-block-line-group:not(.highlight-focus) .code-block-line.highlight:last-child { - box-shadow: inset 0 -1px 0 0 rgba(var(--accent-primary), 0.2); + box-shadow: inset 0 -1px 0 0 rgba(var(--accent), 0.2); } :is(.dark) .code-block-line-group:not(.highlight-focus) .code-block-line.highlight:has(+ .code-block-line:not(.highlight)), :is(.dark) .code-block-line-group:not(.highlight-focus) .code-block-line.highlight:last-child { - box-shadow: inset 0 -1px 0 0 rgba(var(--accent-primary), 0.33); + box-shadow: inset 0 -1px 0 0 rgba(var(--accent), 0.33); } .code-block-line-group:not(.highlight-focus) @@ -85,8 +85,8 @@ .code-block-line.highlight:first-child:has(+ .code-block-line:not(.highlight)), .code-block-line-group:not(.highlight-focus) .code-block-line:not(.highlight) + .code-block-line.highlight:last-child { box-shadow: - inset 0 -1px 0 0 rgba(var(--accent-primary), 0.2), - inset 0 1px 0 0 rgba(var(--accent-primary), 0.2); + inset 0 -1px 0 0 rgba(var(--accent), 0.2), + inset 0 1px 0 0 rgba(var(--accent), 0.2); } :is(.dark) @@ -101,8 +101,8 @@ .code-block-line:not(.highlight) + .code-block-line.highlight:last-child { box-shadow: - inset 0 -1px 0 0 rgba(var(--accent-primary), 0.33), - inset 0 1px 0 0 rgba(var(--accent-primary), 0.33); + inset 0 -1px 0 0 rgba(var(--accent), 0.33), + inset 0 1px 0 0 rgba(var(--accent), 0.33); } :is(.dark) .code-block-root, diff --git a/packages/ui/app/src/util/generateRadixColors.ts b/packages/ui/app/src/util/generateRadixColors.ts new file mode 100644 index 0000000000..b4fe3f4c13 --- /dev/null +++ b/packages/ui/app/src/util/generateRadixColors.ts @@ -0,0 +1,615 @@ +// adapted from: https://github.com/radix-ui/website/blob/main/components/generateRadixColors.tsx +import * as RadixColors from "@radix-ui/colors"; +import BezierEasing from "bezier-easing"; +import Color from "colorjs.io"; + +type ArrayOf12 = [T, T, T, T, T, T, T, T, T, T, T, T]; +const arrayOf12 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] as const; + +// prettier-ignore +const grayScaleNames = ["gray", "mauve", "slate", "sage", "olive", "sand"] as const; + +// prettier-ignore +const scaleNames = [...grayScaleNames, "tomato", "red", "ruby", "crimson", "pink", +"plum", "purple", "violet", "iris", "indigo", "blue", "cyan", "teal", "jade", "green", +"grass", "brown", "orange", "sky", "mint", "lime", "yellow", "amber"] as const; + +const lightColors = Object.fromEntries( + scaleNames.map((scaleName) => [ + scaleName, + Object.values(RadixColors[`${scaleName}P3`]).map((str) => new Color(str).to("oklch")), + ]), +) as Record<(typeof scaleNames)[number], ArrayOf12>; + +const darkColors = Object.fromEntries( + scaleNames.map((scaleName) => [ + scaleName, + Object.values(RadixColors[`${scaleName}DarkP3`]).map((str) => new Color(str).to("oklch")), + ]), +) as Record<(typeof scaleNames)[number], ArrayOf12>; + +export const lightGrayColors = Object.fromEntries( + grayScaleNames.map((scaleName) => [ + scaleName, + Object.values(RadixColors[`${scaleName}P3`]).map((str) => new Color(str).to("oklch")), + ]), +) as Record<(typeof grayScaleNames)[number], ArrayOf12>; + +export const darkGrayColors = Object.fromEntries( + grayScaleNames.map((scaleName) => [ + scaleName, + Object.values(RadixColors[`${scaleName}DarkP3`]).map((str) => new Color(str).to("oklch")), + ]), +) as Record<(typeof grayScaleNames)[number], ArrayOf12>; + +interface GenerateRadixColorsResult { + accentScale: ArrayOf12; + accentScaleAlpha: ArrayOf12; + accentScaleWideGamut: ArrayOf12; + accentScaleAlphaWideGamut: ArrayOf12; + accentContrast: string; + + grayScale: ArrayOf12; + grayScaleAlpha: ArrayOf12; + grayScaleWideGamut: ArrayOf12; + grayScaleAlphaWideGamut: ArrayOf12; + + graySurface: string; + graySurfaceWideGamut: string; + + accentSurface: string; + accentSurfaceWideGamut: string; + + background: string; +} + +export const generateRadixColors = ({ + appearance, + ...args +}: { + appearance: "light" | "dark"; + accent: string; + gray: string; + background: string; +}): GenerateRadixColorsResult => { + const allScales = appearance === "light" ? lightColors : darkColors; + const grayScales = appearance === "light" ? lightGrayColors : darkGrayColors; + const backgroundColor = new Color(args.background).to("oklch"); + + const grayBaseColor = new Color(args.gray).to("oklch"); + const grayScaleColors = getScaleFromColor(grayBaseColor, grayScales, backgroundColor); + + const accentBaseColor = new Color(args.accent).to("oklch"); + + let accentScaleColors = getScaleFromColor(accentBaseColor, allScales, backgroundColor); + + // Enforce srgb for the background color + const backgroundHex = backgroundColor.to("srgb").toString({ format: "hex" }); + + // Make sure we use the tint from the gray scale for when base is pure white or black + const accentBaseHex = accentBaseColor.to("srgb").toString({ format: "hex" }); + if (accentBaseHex === "#000" || accentBaseHex === "#fff") { + accentScaleColors = grayScaleColors.map((color) => color.clone()) as ArrayOf12; + } + + const [accent9Color, accentContrastColor] = getStep9Colors(accentScaleColors, accentBaseColor); + + accentScaleColors[8] = accent9Color; + accentScaleColors[9] = getButtonHoverColor(accent9Color, [accentScaleColors]); + + // Limit saturation of the text colors + accentScaleColors[10].coords[1] = Math.min( + Math.max(accentScaleColors[8].coords[1], accentScaleColors[7].coords[1]), + accentScaleColors[10].coords[1], + ); + accentScaleColors[11].coords[1] = Math.min( + Math.max(accentScaleColors[8].coords[1], accentScaleColors[7].coords[1]), + accentScaleColors[11].coords[1], + ); + + const accentScaleHex = accentScaleColors.map((color) => + color.to("srgb").toString({ format: "hex" }), + ) as ArrayOf12; + + const accentScaleWideGamut = accentScaleColors.map(toOklchString) as ArrayOf12; + + const accentScaleAlphaHex = accentScaleHex.map((color) => + getAlphaColorSrgb(color, backgroundHex), + ) as ArrayOf12; + + const accentScaleAlphaWideGamutString = accentScaleHex.map((color) => + getAlphaColorP3(color, backgroundHex), + ) as ArrayOf12; + + const accentContrastColorHex = accentContrastColor.to("srgb").toString({ format: "hex" }); + + const grayScaleHex = grayScaleColors.map((color) => + color.to("srgb").toString({ format: "hex" }), + ) as ArrayOf12; + + const grayScaleWideGamut = grayScaleColors.map(toOklchString) as ArrayOf12; + + const grayScaleAlphaHex = grayScaleHex.map((color) => getAlphaColorSrgb(color, backgroundHex)) as ArrayOf12; + + const grayScaleAlphaWideGamutString = grayScaleHex.map((color) => + getAlphaColorP3(color, backgroundHex), + ) as ArrayOf12; + + const accentSurfaceHex = + appearance === "light" + ? getAlphaColorSrgb(accentScaleHex[1], backgroundHex, 0.8) + : getAlphaColorSrgb(accentScaleHex[1], backgroundHex, 0.5); + + const accentSurfaceWideGamutString = + appearance === "light" + ? getAlphaColorP3(accentScaleWideGamut[1], backgroundHex, 0.8) + : getAlphaColorP3(accentScaleWideGamut[1], backgroundHex, 0.5); + + return { + accentScale: accentScaleHex, + accentScaleAlpha: accentScaleAlphaHex, + accentScaleWideGamut, + accentScaleAlphaWideGamut: accentScaleAlphaWideGamutString, + accentContrast: accentContrastColorHex, + + grayScale: grayScaleHex, + grayScaleAlpha: grayScaleAlphaHex, + grayScaleWideGamut, + grayScaleAlphaWideGamut: grayScaleAlphaWideGamutString, + + graySurface: appearance === "light" ? "#ffffffcc" : "rgba(0, 0, 0, 0.05)", + graySurfaceWideGamut: appearance === "light" ? "color(display-p3 1 1 1 / 80%)" : "color(display-p3 0 0 0 / 5%)", + + accentSurface: accentSurfaceHex, + accentSurfaceWideGamut: accentSurfaceWideGamutString, + + background: backgroundHex, + }; +}; + +function getStep9Colors(scale: ArrayOf12, accentBaseColor: Color): [Color, Color] { + const referenceBackgroundColor = scale[0]; + const distance = accentBaseColor.deltaEOK(referenceBackgroundColor) * 100; + + // If the accent base color is close to the page background color, it's likely + // white on white or black on black, so we want to return something that makes sense instead + if (distance < 25) { + return [scale[8], getTextColor(scale[8])]; + } + + return [accentBaseColor, getTextColor(accentBaseColor)]; +} + +function getButtonHoverColor(source: Color, scales: ArrayOf12[]) { + const [L, C, H] = source.coords; + const newL = L > 0.4 ? L - 0.03 / (L + 0.1) : L + 0.03 / (L + 0.1); + const newC = L > 0.4 && !isNaN(H) ? C * 0.93 + 0 : C; + const buttonHoverColor = new Color("oklch", [newL, newC, H]); + + // Find closest in-scale color to donate the chroma and hue. + // Especially useful when the source color is pure white or black, + // but the gray scale is tinted. + let closestColor = buttonHoverColor; + let minDistance = Infinity; + + scales.forEach((scale) => { + for (const color of scale) { + const distance = buttonHoverColor.deltaEOK(color); + if (distance < minDistance) { + minDistance = distance; + closestColor = color; + } + } + }); + + buttonHoverColor.coords[1] = closestColor.coords[1]; + buttonHoverColor.coords[2] = closestColor.coords[2]; + return buttonHoverColor; +} + +export function getClosestGrayScale(source: string): (typeof grayScaleNames)[number] { + try { + const sourceColor = new Color(source).to("oklch"); + const scales = { ...lightGrayColors, ...darkGrayColors }; + const allColors: { scale: string; color: Color; distance: number }[] = []; + + Object.entries(scales).forEach(([name, scale]) => { + for (const color of scale) { + const distance = sourceColor.deltaEOK(color); + allColors.push({ scale: name, distance, color }); + } + }); + + allColors.sort((a, b) => a.distance - b.distance); + + return allColors[0].scale as (typeof grayScaleNames)[number]; + } catch (e) { + // eslint-disable-next-line no-console + console.error(e); + return "gray"; + } +} + +function getScaleFromColor(source: Color, scales: Record>, backgroundColor: Color) { + const allColors: { scale: string; color: Color; distance: number }[] = []; + + Object.entries(scales).forEach(([name, scale]) => { + for (const color of scale) { + const distance = source.deltaEOK(color); + allColors.push({ scale: name, distance, color }); + } + }); + + allColors.sort((a, b) => a.distance - b.distance); + + // Remove non-unique scales + const closestColors = allColors.filter( + (color, i, arr) => i === arr.findIndex((value) => value.scale === color.scale), + ); + + // If the next two closest colors are both grays, remove the second one until it’s not a gray anymore. + // This is because up next we will be comparing how close the two closest colors are to the source color, + // and since the grays are all extremely close to each other, we won’t get any useful data from the second + // closest color if it’s also a gray. + const grayScaleNamesStr = grayScaleNames as readonly string[]; + const allAreGrays = closestColors.every((color) => grayScaleNamesStr.includes(color.scale)); + if (!allAreGrays && grayScaleNamesStr.includes(closestColors[0].scale)) { + while (grayScaleNamesStr.includes(closestColors[1].scale)) { + closestColors.splice(1, 1); + } + } + + const colorA = closestColors[0]; + const colorB = closestColors[1]; + + // Light trigonometry ahead. + // + // We want to determine the color that is the closest to the source color. Sometimes it makes sense + // to proportionally mix the two closest colors together, but sometimes it is not useful at all. + // Color coords are spatial in 3D, however we can treat the data we have as a 2D projection that is good enough. + // + // Case 1: + // If the distances between the source color, the 1st closest color (A) and the 2nd closest color (B) form + // a triangle where NEITHER angle A nor B are larger than 90 degrees, then we want to mix the 1st and the 2nd + // closest colors in the same proportion as distances AD and BD are to each other. Mixing the two would result + // in a color that would be closer to the source color than either of the two original closest colors. + // Example: source color is a desaturated blue, which is between "indigo" and "slate" scales. + // + // C ← Source color + // /|⟍ + // / | ⟍ + // b / | ⟍ a + // / | ⟍ + // / | ⟍ + // A --- D -------- B + // ↑ + // The color we want to use as the base, which is a mix of A and B. + // + // Case 2: + // If the distances between the source color, the 1st closest color (A) and the 2nd closest color (B) form + // a triangle where EITHER angle A or B are larger than 90 degrees, then we don’t care about point B because it’s + // directionally the same as A, as mixing A and B can’t provide us with a color that is any closer to the source. + // Example: source color is a saturated blue, with "blue" being the closest scale, and "indigo" just being further. + // + // C ← Source color + // \⟍ + // \ ⟍ + // \ ⟍ a + // b \ ⟍ + // \ ⟍ + // A ------- B + // ↑ + // The color we want to use as the base, which is not influenced by B. + + // We’ll need all the lengths of the triangle sides, named after the angles they look at: + const a = colorB.distance; + const b = colorA.distance; + const c = colorA.color.deltaEOK(colorB.color); + + // We can get the ratios of AD to BD lengths with trigonometry using tangents, + // as the ratio of the tangents of the opposite angles will match. + const cosA = (b ** 2 + c ** 2 - a ** 2) / (2 * b * c); + const radA = Math.acos(cosA); + const sinA = Math.sin(radA); + + const cosB = (a ** 2 + c ** 2 - b ** 2) / (2 * a * c); + const radB = Math.acos(cosB); + const sinB = Math.sin(radB); + + // Tangent of angle C in the ACD triangle + const tanC1 = cosA / sinA; + + // Tangent of angle C in the BCD triangle + const tanC2 = cosB / sinB; + + // The ratio of the tangents corresponds to the ratio of the distances AD to BD + // In the end, it means how much of scale B we want to mix into scale A. + // If it’s "0" or less, this is an obtuse triangle from case 2, and we use just scale A. + const ratio = Math.max(0, tanC1 / tanC2) * 0.5; + + // The base scale is going to be a mix of the two closest scales, with the mix ratio we determined before + const scaleA = scales[colorA.scale]; + const scaleB = scales[colorB.scale]; + const scale = arrayOf12.map((i) => + new Color(Color.mix(scaleA[i], scaleB[i], ratio)).to("oklch"), + ) as ArrayOf12; + + // Get the closest color from the pre-mixed scale we created + const baseColor = scale.slice().sort((a, b) => source.deltaEOK(a) - source.deltaEOK(b))[0]; + + // Note the chroma difference between the source color and the base color + const ratioC = source.coords[1] / baseColor.coords[1]; + + // Modify hue and chroma of the scale to match the source color + scale.forEach((color) => { + color.coords[1] = Math.min(source.coords[1] * 1.5, color.coords[1] * ratioC); + color.coords[2] = source.coords[2]; + }); + + // Light mode + if (scale[0].coords[0] > 0.5) { + const lightnessScale = scale.map(({ coords }) => coords[0]); + const backgroundL = Math.max(0, Math.min(1, backgroundColor.coords[0])); + const newLightnessScale = transposeProgressionStart( + backgroundL, + // Add white as the first "step" of the light scale + [1, ...lightnessScale], + lightModeEasing, + ); + + // Remove the step we added + newLightnessScale.shift(); + + newLightnessScale.forEach((lightness, i) => { + scale[i].coords[0] = lightness; + }); + + return scale; + } + + // Dark mode + const ease: typeof darkModeEasing = [...darkModeEasing]; + const referenceBackgroundColorL = scale[0].coords[0]; + const backgroundColorL = Math.max(0, Math.min(1, backgroundColor.coords[0])); + + // If background is lighter than step 0, we want to gradually change the easing to linear + const ratioL = backgroundColorL / referenceBackgroundColorL; + + if (ratioL > 1) { + const maxRatio = 1.5; + + for (let i = 0; i < ease.length; i++) { + const metaRatio = (ratioL - 1) * (maxRatio / (maxRatio - 1)); + ease[i] = ratioL > maxRatio ? 0 : Math.max(0, ease[i] * (1 - metaRatio)); + } + } + + const lightnessScale = scale.map(({ coords }) => coords[0]); + const backgroundL = backgroundColor.coords[0]; + const newLightnessScale = transposeProgressionStart(backgroundL, lightnessScale, ease); + + newLightnessScale.forEach((lightness, i) => { + scale[i].coords[0] = lightness; + }); + + return scale; +} + +function getTextColor(background: Color) { + const white = new Color("oklch", [1, 0, 0]); + + if (Math.abs(white.contrastAPCA(background)) < 40) { + const [_L, C, H] = background.coords; + return new Color("oklch", [0.25, Math.max(0.08 * C, 0.04), H]); + } + + return white; +} + +// target = background * (1 - alpha) + foreground * alpha +// alpha = (target - background) / (foreground - background) +// Expects 0-1 numbers for the RGB channels +function getAlphaColor( + targetRgb: number[], + backgroundRgb: number[], + rgbPrecision: number, + alphaPrecision: number, + targetAlpha?: number, +) { + const [tr, tg, tb] = targetRgb.map((c) => Math.round(c * rgbPrecision)); + const [br, bg, bb] = backgroundRgb.map((c) => Math.round(c * rgbPrecision)); + + if ( + tr === undefined || + tg === undefined || + tb === undefined || + br === undefined || + bg === undefined || + bb === undefined + ) { + throw Error("Color is undefined"); + } + + // Is the background color lighter, RGB-wise, than target color? + // Decide whether we want to add as little color or as much color as possible, + // darkening or lightening the background respectively. + // If at least one of the bits of the target RGB value + // is lighter than the background, we want to lighten it. + let desiredRgb = 0; + if (tr > br) { + desiredRgb = rgbPrecision; + } else if (tg > bg) { + desiredRgb = rgbPrecision; + } else if (tb > bb) { + desiredRgb = rgbPrecision; + } + + const alphaR = (tr - br) / (desiredRgb - br); + const alphaG = (tg - bg) / (desiredRgb - bg); + const alphaB = (tb - bb) / (desiredRgb - bb); + + const isPureGray = [alphaR, alphaG, alphaB].every((alpha) => alpha === alphaR); + + // No need for precision gymnastics with pure grays, and we can get cleaner output + if (!targetAlpha && isPureGray) { + // Convert back to 0-1 values + const V = desiredRgb / rgbPrecision; + return [V, V, V, alphaR] as const; + } + + const clampRgb = (n: number) => (isNaN(n) ? 0 : Math.min(rgbPrecision, Math.max(0, n))); + const clampA = (n: number) => (isNaN(n) ? 0 : Math.min(alphaPrecision, Math.max(0, n))); + const maxAlpha = targetAlpha ?? Math.max(alphaR, alphaG, alphaB); + + const A = clampA(Math.ceil(maxAlpha * alphaPrecision)) / alphaPrecision; + let R = clampRgb(((br * (1 - A) - tr) / A) * -1); + let G = clampRgb(((bg * (1 - A) - tg) / A) * -1); + let B = clampRgb(((bb * (1 - A) - tb) / A) * -1); + + R = Math.ceil(R); + G = Math.ceil(G); + B = Math.ceil(B); + + const blendedR = blendAlpha(R, A, br); + const blendedG = blendAlpha(G, A, bg); + const blendedB = blendAlpha(B, A, bb); + + // Correct for rounding errors in light mode + if (desiredRgb === 0) { + if (tr <= br && tr !== blendedR) { + R = tr > blendedR ? R + 1 : R - 1; + } + + if (tg <= bg && tg !== blendedG) { + G = tg > blendedG ? G + 1 : G - 1; + } + + if (tb <= bb && tb !== blendedB) { + B = tb > blendedB ? B + 1 : B - 1; + } + } + + // Correct for rounding errors in dark mode + if (desiredRgb === rgbPrecision) { + if (tr >= br && tr !== blendedR) { + R = tr > blendedR ? R + 1 : R - 1; + } + + if (tg >= bg && tg !== blendedG) { + G = tg > blendedG ? G + 1 : G - 1; + } + + if (tb >= bb && tb !== blendedB) { + B = tb > blendedB ? B + 1 : B - 1; + } + } + + // Convert back to 0-1 values + R = R / rgbPrecision; + G = G / rgbPrecision; + B = B / rgbPrecision; + + return [R, G, B, A] as const; +} + +// Important – I empirically discovered that this rounding is how the browser actually overlays +// transparent RGB bits over each other. It does NOT round the whole result altogether. +function blendAlpha(foreground: number, alpha: number, background: number, round = true) { + if (round) { + return Math.round(background * (1 - alpha)) + Math.round(foreground * alpha); + } + + return background * (1 - alpha) + foreground * alpha; +} + +function getAlphaColorSrgb(targetColor: string, backgroundColor: string, targetAlpha?: number) { + const [r, g, b, a] = getAlphaColor( + new Color(targetColor).to("srgb").coords, + new Color(backgroundColor).to("srgb").coords, + 255, + 255, + targetAlpha, + ); + + return formatHex(new Color("srgb", [r, g, b], a).toString({ format: "hex" })); +} + +export function getAlphaColorP3(targetColor: string, backgroundColor: string, targetAlpha?: number): string { + const [r, g, b, a] = getAlphaColor( + new Color(targetColor).to("p3").coords, + new Color(backgroundColor).to("p3").coords, + // Not sure why, but the resulting P3 alpha colors are blended in the browser most precisely when + // rounded to 255 integers too. Is the browser using 0-255 rather than 0-1 under the hood for P3 too? + 255, + 1000, + targetAlpha, + ); + + return ( + new Color("p3", [r, g, b], a) + .toString({ precision: 4 }) + // Important: in non-browser environments colorjs.io outputs a different format for some reason + .replace("color(p3 ", "color(display-p3 ") + ); +} + +// Format shortform hex to longform +function formatHex(str: string) { + if (!str.startsWith("#")) { + return str; + } + + if (str.length === 4) { + const hash = str.charAt(0); + const r = str.charAt(1); + const g = str.charAt(2); + const b = str.charAt(3); + return hash + r + r + g + g + b + b; + } + + if (str.length === 5) { + const hash = str.charAt(0); + const r = str.charAt(1); + const g = str.charAt(2); + const b = str.charAt(3); + const a = str.charAt(4); + return hash + r + r + g + g + b + b + a + a; + } + + return str; +} + +const darkModeEasing = [1, 0, 1, 0] as [number, number, number, number]; +const lightModeEasing = [0, 2, 0, 2] as [number, number, number, number]; + +export function transposeProgressionStart( + to: number, + arr: number[], + curve: [number, number, number, number], +): number[] { + return arr.map((n, i, arr) => { + const lastIndex = arr.length - 1; + const diff = arr[0] - to; + const fn = BezierEasing(...curve); + return n - diff * fn(1 - i / lastIndex); + }); +} + +export function transposeProgressionEnd(to: number, arr: number[], curve: [number, number, number, number]): number[] { + return arr.map((n, i, arr) => { + const lastIndex = arr.length - 1; + const diff = arr[lastIndex] - to; + const fn = BezierEasing(...curve); + return n - diff * fn(i / lastIndex); + }); +} + +// Convert to OKLCH string with percentage for the lightness channel +// https://github.com/radix-ui/themes/issues/420 +function toOklchString(color: Color) { + const L = +(color.coords[0] * 100).toFixed(1); + return color + .to("oklch") + .toString({ precision: 4 }) + .replace(/(\S+)(.+)/, `oklch(${L}%$2`); +} diff --git a/packages/ui/tailwind.config.js b/packages/ui/tailwind.config.js index d77f9dadab..a97b60adbe 100644 --- a/packages/ui/tailwind.config.js +++ b/packages/ui/tailwind.config.js @@ -40,8 +40,8 @@ module.exports = { "8xl": "88rem", }, boxShadow: { - header: "0px 4px 24px 0px rgba(var(--accent-primary), 10%)", - "header-dark": "0px 4px 24px 0px rgba(var(--accent-primary), 10%)", + header: "0px 4px 24px 0px rgba(var(--accent), 10%)", + "header-dark": "0px 4px 24px 0px rgba(var(--accent), 10%)", "card-light": "0 1px 2px rgba(17,20,24,.06)", "card-light-elevated": "0 1px 2px rgba(17,20,24,.2), 0 3px 6px rgba(17,20,24,.06)", "card-dark": "0 2px 4px rgba(221, 243, 255,.07)", @@ -52,13 +52,14 @@ module.exports = { "fern-green": "#49932B", "fern-green-dark": "#ADFF8C", - "accent-primary": withOpacity("--accent-primary"), - "accent-primary-aa": withOpacity("--accent-primary-aa"), - "accent-primary-aaa": withOpacity("--accent-primary-aaa"), - "accent-primary-tinted": withOpacity("--accent-primary-tinted"), - "accent-primary-contrast": withOpacity("--accent-primary-contrast"), - "accent-highlight": "rgba(var(--accent-primary), 20%)", - "accent-highlight-faded": "rgba(var(--accent-primary), 10%)", + accent: withOpacity("--accent"), + "accent-aa": withOpacity("--accent-aa"), + "accent-aaa": withOpacity("--accent-aaa"), + "accent-tinted": "var(--accent-10)", + "accent-contrast": withOpacity("--accent-contrast"), + "accent-muted": `var(--accent-6)`, + "accent-highlight": "var(--accent-3)", + "accent-highlight-faded": "var(--accent-2)", background: withOpacity("--background"), "method-get": "var(--green-a10)", @@ -107,20 +108,21 @@ module.exports = { }, "card-background": "var(--card-background)", + "card-solid": "var(--card-background-solid)", "sidebar-background": "var(--sidebar-background)", "header-background": "var(--header-background)", // "border-default": "var(--grayscale-a5)", "border-default": "var(--border)", "border-concealed": "var(--border-concealed)", - "border-accent-muted": "rgba(var(--accent-primary), 0.50)", + "border-accent-muted": "rgba(var(--accent), 0.50)", "border-warning": "var(--amber-a8)", "border-success": "var(--green-a8)", "border-danger": "var(--red-a8)", "border-info": "var(--blue-a8)", "border-default-soft": "var(--grayscale-a6)", - "border-primary-soft": "rgba(var(--accent-primary), 30%)", + "border-primary-soft": "rgba(var(--accent), 30%)", "border-warning-soft": "var(--amber-a6)", "border-success-soft": "var(--green-a6)", "border-danger-soft": "var(--red-a6)", @@ -133,7 +135,7 @@ module.exports = { faded: "var(--grayscale-a9)", "tag-default-soft": "var(--grayscale-a2)", - "tag-primary-soft": "rgba(var(--accent-primary), 10%)", + "tag-primary-soft": "rgba(var(--accent), 10%)", "tag-warning-soft": "var(--amber-a2)", "tag-success-soft": "var(--green-a2)", "tag-danger-soft": "var(--red-a2)", @@ -142,7 +144,7 @@ module.exports = { "tag-default": "var(--grayscale-a3)", "tag-default-solid": "var(--grayscale-3)", "tag-default-hover": "var(--grayscale-a4)", - "tag-primary": "rgba(var(--accent-primary), 15%)", + "tag-primary": "rgba(var(--accent), 15%)", "tag-warning": "var(--amber-a3)", "tag-success": "var(--green-a3)", "tag-danger": "var(--red-a3)", @@ -288,13 +290,13 @@ module.exports = { "@apply text-text-muted dark:text-text-muted dark:[text-shadow:_0_1px_3px_rgb(0_0_0_/_40%)]": {}, }, ".t-accent": { - "@apply text-accent-primary-aa": {}, + "@apply text-accent-aa": {}, }, ".t-accent-aaa": { - "@apply text-accent-primary-aaa": {}, + "@apply text-accent-aaa": {}, }, ".t-accent-contrast": { - "@apply text-accent-primary-contrast": {}, + "@apply text-accent-contrast": {}, }, ".t-success": { "@apply text-intent-success": {}, @@ -321,29 +323,8 @@ module.exports = { ".bg-card": { "@apply bg-card-background": {}, }, - ".bg-accent": { - "@apply bg-accent-primary": {}, - }, - ".bg-accent-muted": { - "@apply bg-accent-primary/70": {}, - }, - ".bg-accent-aa": { - "@apply bg-accent-primary-aa": {}, - }, - ".bg-accent-aaa": { - "@apply bg-accent-primary-aaa": {}, - }, - ".bg-accent-contrast": { - "@apply bg-accent-primary-contrast": {}, - }, - ".bg-accent-tinted": { - "@apply bg-accent-primary-tinted": {}, - }, - ".bg-accent-highlight": { - "@apply bg-accent-primary/20": {}, - }, - ".bg-accent-highlight-faded": { - "@apply bg-accent-primary/10": {}, + ".bg-card-surface": { + "@apply bg-card dark:bg-white/5": {}, }, ".bg-border-primary": { "@apply bg-border-accent-muted": {}, @@ -437,9 +418,6 @@ module.exports = { ".shadow-border-primary": { "@apply shadow-border-accent-muted": {}, }, - ".shadow-accent": { - "@apply shadow-accent-primary": {}, - }, ".shadow-card": { "@apply shadow-card-light": {}, }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a393a00680..c2e4fdab30 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -843,9 +843,15 @@ importers: algoliasearch: specifier: ^4.22.1 version: 4.22.1 + bezier-easing: + specifier: ^2.1.0 + version: 2.1.0 clsx: specifier: ^2.1.0 version: 2.1.0 + colorjs.io: + specifier: ^0.5.0 + version: 0.5.0 estree-util-visit: specifier: ^2.0.0 version: 2.0.0 @@ -11112,6 +11118,10 @@ packages: open: 8.4.2 dev: true + /bezier-easing@2.1.0: + resolution: {integrity: sha512-gbIqZ/eslnUFC1tjEvtz0sgx+xTK20wDnYMIA27VA04R7w6xxXQPZDbibjA9DTWZRA2CXtwHykkVzlCaAJAZig==} + dev: false + /big-integer@1.6.52: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} @@ -11735,6 +11745,10 @@ packages: resolution: {integrity: sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==} dev: true + /colorjs.io@0.5.0: + resolution: {integrity: sha512-qekjTiBLM3F/sXKks/ih5aWaHIGu+Ftel0yKEvmpbKvmxpNOhojKgha5uiWEUOqEpRjC1Tq3nJRT7WgdBOxIGg==} + dev: false + /colors@1.4.0: resolution: {integrity: sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==} engines: {node: '>=0.1.90'}