diff --git a/.github/workflows/release-npm-package.yaml b/.github/workflows/release-npm-package.yaml index b808eb8..eff31a3 100644 --- a/.github/workflows/release-npm-package.yaml +++ b/.github/workflows/release-npm-package.yaml @@ -23,7 +23,7 @@ jobs: run: | latest_version=$(npm view @redhat-developer/red-hat-developer-hub-theme@latest version) npm version $latest_version --no-git-tag-version - npm version patch --no-git-tag-version + npm version minor --no-git-tag-version npm publish --access=public --tag=latest env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d8f91bd..5f7c089 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -20,12 +20,15 @@ import { themes as backstageTheme, createUnifiedTheme, UnifiedTheme, + UnifiedThemeOptions, UnifiedThemeProvider, } from "@backstage/theme"; import * as rhdh10 from "../src/themes/rhdh-1.0"; import * as rhdh11 from "../src/themes/rhdh-1.1"; -import * as rhdh from "../src/themes/rhdh"; +import * as rhdh120 from "../src/themes/rhdh-1.2.0"; +import { ThemeConfig } from "../src/types"; +import { useTheme } from "../src/hooks/useTheme"; const configApi = new ConfigReader({}); const alertApi = new AlertApiForwarder(); @@ -51,16 +54,94 @@ const rhdhColors = { }, }; -const themes: Record = { - "Backstage Light": backstageTheme.light, - "Backstage Dark": backstageTheme.dark, +type Theme = + | { unifiedTheme: UnifiedTheme } + | { unifiedThemeOptions: UnifiedThemeOptions } + | { themeConfig: ThemeConfig }; - "RHDH Light 1-0": rhdh10.customLightTheme(rhdhColors.light), - "RHDH Dark 1-0": rhdh10.customDarkTheme(rhdhColors.dark), - "RHDH Light 1-1": rhdh11.customLightTheme(rhdhColors.light), - "RHDH Dark 1-1": rhdh11.customDarkTheme(rhdhColors.dark), - "RHDH Light latest": rhdh.customLightTheme(rhdhColors.light), - "RHDH Dark latest": rhdh.customDarkTheme(rhdhColors.dark), +const themes: Record = { + "Backstage Light": { unifiedTheme: backstageTheme.light }, + "Backstage Dark": { unifiedTheme: backstageTheme.dark }, + + // Use underline instead of dot, because otherwise the theme switcher + // will not save the selected theme in local storage. + "RHDH Light 1-0": { + unifiedThemeOptions: rhdh10.customLightTheme(rhdhColors.light), + }, + "RHDH Dark 1-0": { + unifiedThemeOptions: rhdh10.customDarkTheme(rhdhColors.dark), + }, + "RHDH Light 1-1": { + unifiedThemeOptions: rhdh11.customLightTheme(rhdhColors.light), + }, + "RHDH Dark 1-1": { + unifiedThemeOptions: rhdh11.customDarkTheme(rhdhColors.dark), + }, + "RHDH Light 1-2-0": { unifiedThemeOptions: rhdh120.customLightTheme({}) }, + "RHDH Dark 1-2-0": { unifiedThemeOptions: rhdh120.customDarkTheme({}) }, + + "RHDH Light latest": { + themeConfig: {}, + }, + "RHDH Dark latest": { + themeConfig: { + mode: "dark", + }, + }, + "RHDH Light customized old": { + themeConfig: { + primaryColor: "#be0000", + headerColor1: "#be0000", + headerColor2: "#f56d6d", + navigationIndicatorColor: "#be0000", + } as ThemeConfig, + }, + "RHDH Dark customized old": { + themeConfig: { + mode: "dark", + primaryColor: "#be0000", + headerColor1: "#be0000", + headerColor2: "#f56d6d", + navigationIndicatorColor: "#be0000", + } as ThemeConfig, + }, + "RHDH Light customized new": { + themeConfig: { + palette: { + primary: { + main: "#ff0000", + }, + secondary: { + main: "#00ff00", + }, + }, + defaultPageTheme: "home", + pageTheme: { + home: { + backgroundColor: ["#ff0000", "#00ff00"], + }, + }, + }, + }, + "RHDH Dark customized new": { + themeConfig: { + mode: "dark", + palette: { + primary: { + main: "#ff0000", + }, + secondary: { + main: "#00ff00", + }, + }, + defaultPageTheme: "home", + pageTheme: { + home: { + backgroundColor: ["#ff0000", "#00ff00"], + }, + }, + }, + }, }; const defaultTheme = "RHDH Light latest"; @@ -71,7 +152,13 @@ declare global { } } -const ThemeProvider = ({ theme, children }) => { +const ThemeProvider = ({ + theme, + children, +}: { + theme: Theme; + children: React.ReactNode; +}) => { const [overrideTheme, setOverrideTheme] = React.useState( undefined, ); @@ -79,12 +166,39 @@ const ThemeProvider = ({ theme, children }) => { const actualTheme = (overrideTheme && themes[overrideTheme]) || theme; + if ("unifiedTheme" in actualTheme) { + return ( + + {children} + + ); + } else if ("unifiedThemeOptions" in actualTheme) { + return ( + + {children} + + ); + } else { + return ( + + {children} + + ); + } +}; + +const ThemeConfigProvider = ({ + themeConfig, + children, +}: { + themeConfig: ThemeConfig; + children: React.ReactNode; +}) => { + const theme = useTheme(themeConfig); return ( - + {children} ); diff --git a/package-lock.json b/package-lock.json index c2350d5..ccbb608 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@redhat-developer/red-hat-developer-hub-theme", - "version": "0.0.4", + "version": "0.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@redhat-developer/red-hat-developer-hub-theme", - "version": "0.0.4", + "version": "0.0.0", "license": "Apache-2.0", "devDependencies": { "@backstage/cli": "^0.26.2", diff --git a/package.json b/package.json index c150a48..7c168da 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "redhat" ], "homepage": "https://developers.redhat.com/rhdh", - "version": "0.0.4", + "version": "0.0.0", "author": "Red Hat", "maintainers": [ { diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..35237e3 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./useBranding"; +export * from "./useThemeConfig"; +export * from "./useThemeOptions"; diff --git a/src/hooks/useBranding.test.ts b/src/hooks/useBranding.test.ts new file mode 100644 index 0000000..c7fe95d --- /dev/null +++ b/src/hooks/useBranding.test.ts @@ -0,0 +1,65 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useApi } from "@backstage/core-plugin-api"; +import { MockConfigApi } from "@backstage/test-utils"; +import { JsonObject } from "@backstage/types"; +import { Config } from "../types"; +import { useBranding } from "./useBranding"; + +jest.mock("@backstage/core-plugin-api", () => ({ + ...jest.requireActual("@backstage/core-plugin-api"), + useApi: jest.fn(), +})); + +const mockAppConfig = (appConfig: Config) => { + (useApi as jest.Mock).mockReturnValue( + new MockConfigApi(appConfig as unknown as JsonObject), + ); +}; + +describe("useBranding", () => { + it("returns undefined if the config contains no branding", () => { + mockAppConfig({ + app: {}, + }); + const { result } = renderHook(() => useBranding()); + expect(result.current).toBeUndefined(); + }); + + it("returns the branding without theme", () => { + mockAppConfig({ + app: { + branding: {}, + }, + }); + const { result } = renderHook(() => useBranding()); + expect(result.current).toEqual({}); + }); + + it("returns the branding with theme", () => { + mockAppConfig({ + app: { + branding: { + theme: { + emptyTheme: {}, + anotherTheme: { + palette: { + divider: "red", + }, + }, + }, + }, + }, + }); + const { result } = renderHook(() => useBranding()); + expect(result.current).toEqual({ + theme: { + emptyTheme: {}, + anotherTheme: { + palette: { + divider: "red", + }, + }, + }, + }); + }); +}); diff --git a/src/hooks/useBranding.ts b/src/hooks/useBranding.ts new file mode 100644 index 0000000..5893350 --- /dev/null +++ b/src/hooks/useBranding.ts @@ -0,0 +1,16 @@ +import React from "react"; +import { ConfigApi, configApiRef, useApi } from "@backstage/core-plugin-api"; +import { Branding } from "../types"; + +export const useBranding = (): Branding | undefined => { + let configApi: ConfigApi | undefined = undefined; + try { + configApi = useApi(configApiRef); + } catch (err) { + // useApi won't be initialized initially in createApp theme provider, and will get updated later + } + return React.useMemo(() => { + const branding = configApi?.getOptional("app.branding"); + return branding; + }, [configApi]); +}; diff --git a/src/hooks/useTheme.test.ts b/src/hooks/useTheme.test.ts new file mode 100644 index 0000000..99d2652 --- /dev/null +++ b/src/hooks/useTheme.test.ts @@ -0,0 +1,86 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useTheme } from "./useTheme"; + +describe("useTheme", () => { + it("returns rhdh theming options for an empty theme config", () => { + const { result } = renderHook(() => useTheme({})); + const unifiedTheme = result.current; + const v5Theme = unifiedTheme.getTheme("v5")!; + expect(v5Theme).toEqual({ + applyStyles: expect.any(Function), + breakpoints: expect.any(Object), + components: expect.any(Object), + direction: "ltr", + getPageTheme: expect.any(Function), + mixins: expect.any(Object), + page: expect.any(Object), + palette: expect.any(Object), + shadows: expect.any(Object), + shape: expect.any(Object), + spacing: expect.any(Function), + transitions: expect.any(Object), + typography: expect.any(Object), + unstable_sx: expect.any(Function), + unstable_sxConfig: expect.any(Object), + zIndex: expect.any(Object), + }); + }); + + it("returns rhdh theming options for variant rhdh", () => { + const { result } = renderHook(() => + useTheme({ + mode: "dark", + variant: "rhdh", + }), + ); + const unifiedTheme = result.current; + const v5Theme = unifiedTheme.getTheme("v5"); + expect(v5Theme).toEqual({ + applyStyles: expect.any(Function), + breakpoints: expect.any(Object), + components: expect.any(Object), + direction: "ltr", + getPageTheme: expect.any(Function), + mixins: expect.any(Object), + page: expect.any(Object), + palette: expect.any(Object), + shadows: expect.any(Object), + shape: expect.any(Object), + spacing: expect.any(Function), + transitions: expect.any(Object), + typography: expect.any(Object), + unstable_sx: expect.any(Function), + unstable_sxConfig: expect.any(Object), + zIndex: expect.any(Object), + }); + }); + + it("returns backstage theming options for backstage variant", () => { + const { result } = renderHook(() => + useTheme({ + mode: "dark", + variant: "backstage", + }), + ); + const unifiedTheme = result.current; + const v5Theme = unifiedTheme.getTheme("v5"); + expect(v5Theme).toEqual({ + applyStyles: expect.any(Function), + breakpoints: expect.any(Object), + components: expect.any(Object), + direction: "ltr", + getPageTheme: expect.any(Function), + mixins: expect.any(Object), + page: expect.any(Object), + palette: expect.any(Object), + shadows: expect.any(Object), + shape: expect.any(Object), + spacing: expect.any(Function), + transitions: expect.any(Object), + typography: expect.any(Object), + unstable_sx: expect.any(Function), + unstable_sxConfig: expect.any(Object), + zIndex: expect.any(Object), + }); + }); +}); diff --git a/src/hooks/useTheme.ts b/src/hooks/useTheme.ts new file mode 100644 index 0000000..ea0cbae --- /dev/null +++ b/src/hooks/useTheme.ts @@ -0,0 +1,14 @@ +import React from "react"; +import { createUnifiedTheme, type UnifiedTheme } from "@backstage/theme"; +import type { ThemeConfig } from "../types"; +import { useThemeOptions } from "./useThemeOptions"; + +/** Creates a memorized Backstage UnifiedTheme based on the given ThemeConfig. */ +export const useTheme = (themeConfig: ThemeConfig): UnifiedTheme => { + const unifiedThemeOptions = useThemeOptions(themeConfig); + const theme = React.useMemo( + () => createUnifiedTheme(unifiedThemeOptions), + [unifiedThemeOptions], + ); + return theme; +}; diff --git a/src/hooks/useThemeConfig.test.ts b/src/hooks/useThemeConfig.test.ts new file mode 100644 index 0000000..1b60649 --- /dev/null +++ b/src/hooks/useThemeConfig.test.ts @@ -0,0 +1,97 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useApi } from "@backstage/core-plugin-api"; +import { MockConfigApi } from "@backstage/test-utils"; +import { JsonObject } from "@backstage/types"; +import { Config } from "../types"; +import { useThemeConfig } from "./useThemeConfig"; + +jest.mock("@backstage/core-plugin-api", () => ({ + ...jest.requireActual("@backstage/core-plugin-api"), + useApi: jest.fn(), +})); + +const mockAppConfig = (appConfig: Config) => { + (useApi as jest.Mock).mockReturnValue( + new MockConfigApi(appConfig as unknown as JsonObject), + ); +}; + +describe("useThemeConfig", () => { + it("returns an empty config if the config contains no branding", () => { + mockAppConfig({ + app: {}, + }); + const { result } = renderHook(() => useThemeConfig("someTheme")); + expect(result.current).toEqual({ + mode: "light", + }); + }); + + it("returns an empty config if branding contains no theme", () => { + mockAppConfig({ + app: { + branding: {}, + }, + }); + const { result } = renderHook(() => useThemeConfig("someTheme")); + expect(result.current).toEqual({ + mode: "light", + }); + }); + + it("returns the theme config when it is defined", () => { + mockAppConfig({ + app: { + branding: { + theme: { + lightTheme: {}, + darkTheme: {}, + emptyTheme: {}, + anotherTheme: { + mode: "dark", + palette: { + divider: "red", + }, + }, + }, + }, + }, + }); + + const { result: lightTheme } = renderHook(() => + useThemeConfig("lightTheme"), + ); + expect(lightTheme.current).toEqual({ + mode: "light", + }); + + const { result: darkTheme } = renderHook(() => useThemeConfig("darkTheme")); + expect(darkTheme.current).toEqual({ + mode: "dark", + }); + + const { result: emptyTheme } = renderHook(() => + useThemeConfig("emptyTheme"), + ); + expect(emptyTheme.current).toEqual({ + mode: "light", + }); + + const { result: anotherTheme } = renderHook(() => + useThemeConfig("anotherTheme"), + ); + expect(anotherTheme.current).toEqual({ + mode: "dark", + palette: { + divider: "red", + }, + }); + + const { result: notFoundTheme } = renderHook(() => + useThemeConfig("notFoundTheme"), + ); + expect(notFoundTheme.current).toEqual({ + mode: "light", + }); + }); +}); diff --git a/src/hooks/useThemeConfig.ts b/src/hooks/useThemeConfig.ts new file mode 100644 index 0000000..1a32735 --- /dev/null +++ b/src/hooks/useThemeConfig.ts @@ -0,0 +1,26 @@ +import React from "react"; +import { ConfigApi, configApiRef, useApi } from "@backstage/core-plugin-api"; +import { ThemeConfig } from "../types"; + +export const useThemeConfig = (themeName: string): ThemeConfig => { + let configApi: ConfigApi | undefined = undefined; + try { + configApi = useApi(configApiRef); // NOSONAR + } catch (err) { + // useApi won't be initialized initially in createApp theme provider, and will get updated later + } + return React.useMemo(() => { + if (!configApi) { + return { + mode: themeName.includes("dark") ? "dark" : "light", + }; + } + const themeConfig = + configApi.getOptional(`app.branding.theme.${themeName}`) ?? + {}; + if (!themeConfig.mode) { + themeConfig.mode = themeName.includes("dark") ? "dark" : "light"; + } + return themeConfig; + }, [configApi]); +}; diff --git a/src/hooks/useThemeOptions.test.ts b/src/hooks/useThemeOptions.test.ts new file mode 100644 index 0000000..b9ec912 --- /dev/null +++ b/src/hooks/useThemeOptions.test.ts @@ -0,0 +1,66 @@ +import { renderHook } from "@testing-library/react-hooks"; +import { useThemeOptions } from "./useThemeOptions"; + +describe("useThemeOptions", () => { + it("returns rhdh theming options for an empty theme config", () => { + const { result } = renderHook(() => useThemeOptions({})); + expect(result.current).toEqual({ + palette: expect.any(Object), + fontFamily: + 'RedHatText, "Helvetica Neue", -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + typography: expect.any(Object), + defaultPageTheme: "default", + pageTheme: { + default: { + backgroundImage: "none, linear-gradient(90deg, #ffffff, #ffffff)", + colors: ["#ffffff"], + fontColor: "#FFFFFF", + shape: "none", + }, + }, + components: expect.any(Object), + }); + }); + + it("returns rhdh theming options for variant rhdh", () => { + const { result } = renderHook(() => + useThemeOptions({ + mode: "dark", + variant: "rhdh", + }), + ); + expect(result.current).toEqual({ + palette: expect.any(Object), + fontFamily: + 'RedHatText, "Helvetica Neue", -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', + typography: expect.any(Object), + defaultPageTheme: "default", + pageTheme: { + default: { + backgroundImage: "none, linear-gradient(90deg, #0f1214, #0f1214)", + colors: ["#0f1214"], + fontColor: "#FFFFFF", + shape: "none", + }, + }, + components: expect.any(Object), + }); + }); + + it("returns backstage theming options for backstage variant", () => { + const { result } = renderHook(() => + useThemeOptions({ + mode: "dark", + variant: "backstage", + }), + ); + expect(result.current).toEqual({ + palette: expect.any(Object), + fontFamily: undefined, + htmlFontSize: undefined, + typography: undefined, + defaultPageTheme: undefined, + pageTheme: undefined, + }); + }); +}); diff --git a/src/hooks/useThemeOptions.ts b/src/hooks/useThemeOptions.ts new file mode 100644 index 0000000..72173e4 --- /dev/null +++ b/src/hooks/useThemeOptions.ts @@ -0,0 +1,62 @@ +import React from "react"; +import { type UnifiedThemeOptions } from "@backstage/theme"; +import type { ThemeConfig } from "../types"; +import * as backstage from "../themes/backstage"; +import * as rhdh from "../themes/rhdh"; +import * as rhdh10 from "../themes/rhdh-1.0"; +import { type ThemeColors as RHDH10ThemeColors } from "../themes/rhdh-1.0/types"; +import * as rhdh11 from "../themes/rhdh-1.1"; +import * as rhdh120 from "../themes/rhdh-1.2.0"; +import { migrateThemeConfig } from "../utils/migrateTheme"; +import { mergeUnifiedThemeOptions } from "../utils/mergeTheme"; +import { createPageThemes } from "../utils/createPageThemes"; + +/** Creates a memorized Backstage UnifiedThemeOptions based on the given ThemeConfig. */ +export const useThemeOptions = ( + themeConfig: ThemeConfig, +): UnifiedThemeOptions => { + const theme = React.useMemo(() => { + const mode = themeConfig.mode ?? "light"; + const variant = themeConfig.variant ?? "rhdh"; + + if (variant === "rhdh-1.0") { + return rhdh10.customDarkTheme(themeConfig as RHDH10ThemeColors); + } + if (variant === "rhdh-1.1") { + return rhdh11.customDarkTheme(themeConfig as RHDH10ThemeColors); + } + if (variant === "rhdh-1.2.0") { + return rhdh120.customDarkTheme(themeConfig as RHDH10ThemeColors); + } + + let defaultThemeConfig: ThemeConfig; + if (variant === "backstage") { + defaultThemeConfig = backstage.getDefaultThemeConfig(mode); + } else { + defaultThemeConfig = rhdh.getDefaultThemeConfig(mode); + } + + const migratedThemeConfig = migrateThemeConfig(themeConfig); + const mergedThemeConfig = mergeUnifiedThemeOptions( + defaultThemeConfig, + migratedThemeConfig, + ); + + const unifiedThemeOption: UnifiedThemeOptions = { + palette: mergedThemeConfig.palette as UnifiedThemeOptions["palette"], + defaultPageTheme: mergedThemeConfig.defaultPageTheme, + fontFamily: mergedThemeConfig.fontFamily, + htmlFontSize: mergedThemeConfig.htmlFontSize, + typography: mergedThemeConfig.typography, + }; + + unifiedThemeOption.pageTheme = createPageThemes(mergedThemeConfig); + + if (variant !== "backstage") { + unifiedThemeOption.components = rhdh.components(mergedThemeConfig); + } + + return unifiedThemeOption; + }, [themeConfig]); + return theme; +}; diff --git a/src/index.ts b/src/index.ts index a58801e..940c729 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ +export * from "./hooks"; export * from "./themes"; diff --git a/src/themes/backstage/index.test.ts b/src/themes/backstage/index.test.ts new file mode 100644 index 0000000..4fd42c1 --- /dev/null +++ b/src/themes/backstage/index.test.ts @@ -0,0 +1,168 @@ +import { getDefaultThemeConfig } from "./index"; + +// This test will fail when backstage theme is updated. +// +// It is used to ensure that the theme is not broken when it is updated. +// It also helps to make it easier to check backstage theme colors. +// +// To fix the test, update the expected values to match the latest theme. + +describe("getDefaultThemeConfig", () => { + it("should return the correct defaults for light mode", () => { + expect(getDefaultThemeConfig("light")).toEqual({ + variant: "backstage", + mode: "light", + palette: { + background: { + default: "#F8F8F8", + paper: "#FFFFFF", + }, + banner: { + closeButtonColor: "#FFFFFF", + error: "#E22134", + info: "#2E77D0", + link: "#000000", + text: "#FFFFFF", + warning: "#FF9800", + }, + border: "#E6E6E6", + bursts: { + backgroundColor: { + default: "#7C3699", + }, + fontColor: "#FEFEFE", + gradient: { + linear: "linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)", + }, + slackChannelText: "#ddd", + }, + errorBackground: "#FFEBEE", + errorText: "#CA001B", + gold: "#FFD600", + highlight: "#FFFBCC", + infoBackground: "#ebf5ff", + infoText: "#004e8a", + link: "#0A6EBE", + linkHover: "#2196F3", + mode: "light", + navigation: { + background: "#171717", + color: "#b5b5b5", + indicator: "#9BF0E1", + navItem: { + hoverBackground: "#404040", + }, + selectedColor: "#FFF", + submenu: { + background: "#404040", + }, + }, + pinSidebarButton: { + background: "#BDBDBD", + icon: "#181818", + }, + primary: { + main: "#1F5493", + }, + status: { + aborted: "#757575", + error: "#E22134", + ok: "#1DB954", + pending: "#FFED51", + running: "#1F5493", + warning: "#FF9800", + }, + tabbar: { + indicator: "#9BF0E1", + }, + textContrast: "#000000", + textSubtle: "#6E6E6E", + textVerySubtle: "#DDD", + type: "light", + warningBackground: "#F59B23", + warningText: "#000000", + }, + }); + }); + + it("should return the correct defaults for dark mode", () => { + expect(getDefaultThemeConfig("dark")).toEqual({ + variant: "backstage", + mode: "dark", + palette: { + background: { + default: "#333333", + paper: "#424242", + }, + banner: { + closeButtonColor: "#FFFFFF", + error: "#E22134", + info: "#2E77D0", + link: "#000000", + text: "#FFFFFF", + warning: "#FF9800", + }, + border: "#E6E6E6", + bursts: { + backgroundColor: { + default: "#7C3699", + }, + fontColor: "#FEFEFE", + gradient: { + linear: "linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)", + }, + slackChannelText: "#ddd", + }, + errorBackground: "#FFEBEE", + errorText: "#CA001B", + gold: "#FFD600", + highlight: "#FFFBCC", + infoBackground: "#ebf5ff", + infoText: "#004e8a", + link: "#9CC9FF", + linkHover: "#82BAFD", + mode: "dark", + navigation: { + background: "#424242", + color: "#b5b5b5", + indicator: "#9BF0E1", + navItem: { + hoverBackground: "#404040", + }, + selectedColor: "#FFF", + submenu: { + background: "#404040", + }, + }, + pinSidebarButton: { + background: "#BDBDBD", + icon: "#404040", + }, + primary: { + dark: "#82BAFD", + main: "#9CC9FF", + }, + secondary: { + main: "#FF88B2", + }, + status: { + aborted: "#9E9E9E", + error: "#F84C55", + ok: "#71CF88", + pending: "#FEF071", + running: "#3488E3", + warning: "#FFB84D", + }, + tabbar: { + indicator: "#9BF0E1", + }, + textContrast: "#FFFFFF", + textSubtle: "#CCCCCC", + textVerySubtle: "#727272", + type: "dark", + warningBackground: "#F59B23", + warningText: "#000000", + }, + }); + }); +}); diff --git a/src/themes/backstage/index.ts b/src/themes/backstage/index.ts new file mode 100644 index 0000000..4eb8ab8 --- /dev/null +++ b/src/themes/backstage/index.ts @@ -0,0 +1,12 @@ +import { palettes } from "@backstage/theme"; +import { ThemeConfig } from "../../types"; + +export const getDefaultThemeConfig = (mode: "light" | "dark"): ThemeConfig => { + const palette = mode === "dark" ? palettes.dark : palettes.light; + + return { + variant: "backstage", + mode: mode === "dark" ? "dark" : "light", + palette, + }; +}; diff --git a/src/themes/index.tsx b/src/themes/index.tsx index a1d4b15..e2d6866 100644 --- a/src/themes/index.tsx +++ b/src/themes/index.tsx @@ -1,110 +1,168 @@ import React from "react"; import { AppTheme } from "@backstage/core-plugin-api"; -import { UnifiedTheme, UnifiedThemeProvider, themes } from "@backstage/theme"; +import { + UnifiedTheme, + UnifiedThemeProvider, + createUnifiedTheme, + themes, +} from "@backstage/theme"; import LightIcon from "@material-ui/icons/WbSunny"; import DarkIcon from "@material-ui/icons/Brightness2"; import { createTheme } from "@mui/material/styles"; +import { useThemeConfig } from "../hooks/useThemeConfig"; + +import * as backstage from "./backstage"; import * as rhdh10 from "./rhdh-1.0"; import * as rhdh11 from "./rhdh-1.1"; +import * as rhdh120 from "./rhdh-1.2.0"; import * as rhdh from "./rhdh"; -import { - BrandingThemeColors, - useBrandingThemeColors, -} from "./useBrandingThemeColors"; +import { ThemeConfig } from "../types"; +import { useTheme } from "../hooks/useTheme"; -const createProvider = - (theme: UnifiedTheme): AppTheme["Provider"] => - ({ children }) => { +const createThemeProvider = (theme: UnifiedTheme): AppTheme["Provider"] => + function RHDHThemeProvider({ children }) { return ( {children} ); }; -const createBrandedProvider = - ( - brandingThemeName: string, - brandedThemeFactory: ( - brandingThemeColors: BrandingThemeColors, - ) => UnifiedTheme, - ): AppTheme["Provider"] => - ({ children }) => { - const brandingThemeColors = useBrandingThemeColors(brandingThemeName); - const unifiedTheme = React.useMemo( - () => brandedThemeFactory(brandingThemeColors), - [brandingThemeColors], +const createThemeProviderForThemeConfig = ( + themeConfig: ThemeConfig, +): AppTheme["Provider"] => + function RHDHThemeProviderForThemeConfig({ children }) { + const theme = useTheme(themeConfig); + return ( + {children} ); + }; + +const createThemeProviderForThemeName = ( + themeName: string, +): AppTheme["Provider"] => + function RHDHThemeProviderForThemeName({ children }) { + const themeConfig = useThemeConfig(themeName); + const theme = useTheme(themeConfig); return ( - - {children} - + {children} ); }; export const getAllThemes = (): AppTheme[] => { return [ + { + id: "light", + title: "RHDH Light (latest)", + variant: "light", + icon: , + Provider: createThemeProviderForThemeName("light"), + }, + { + id: "dark", + title: "RHDH Dark (latest)", + variant: "dark", + icon: , + Provider: createThemeProviderForThemeName("dark"), + }, + { + id: "light-customized", + title: "RHDH Light (customized)", + variant: "light", + icon: , + Provider: createThemeProviderForThemeConfig({ + mode: "light", + variant: "rhdh", + palette: { + primary: { main: "#ff0000" }, + secondary: { main: "#00ff00" }, + }, + }), + }, + { + id: "dark-customized", + title: "RHDH Dark (customized)", + variant: "dark", + icon: , + Provider: createThemeProviderForThemeConfig({ + mode: "dark", + variant: "rhdh", + palette: { + primary: { main: "#ff0000" }, + secondary: { main: "#00ff00" }, + }, + }), + }, { id: "backstage-light", title: "Backstage Light", variant: "light", icon: , - Provider: createProvider(themes.light), + Provider: createThemeProvider(themes.light), }, { id: "backstage-dark", title: "Backstage Dark", variant: "dark", icon: , - Provider: createProvider(themes.dark), + Provider: createThemeProvider(themes.dark), }, { - id: "rhdh-1.0-light", - title: "RHDH 1.0 Light", + id: "rhdh-1.2.0-light", + title: "RHDH 1.2.0 Light", variant: "light", icon: , - Provider: createProvider(rhdh10.customLightTheme({})), + Provider: createThemeProvider( + createUnifiedTheme(rhdh120.customLightTheme({})), + ), }, { - id: "rhdh-1.0-dark", - title: "RHDH 1.0 Dark", + id: "rhdh-1.2.0-dark", + title: "RHDH 1.2.0 Dark", variant: "dark", icon: , - Provider: createProvider(rhdh10.customDarkTheme({})), + Provider: createThemeProvider( + createUnifiedTheme(rhdh120.customDarkTheme({})), + ), }, { id: "rhdh-1.1-light", title: "RHDH 1.1 Light", variant: "light", icon: , - Provider: createProvider(rhdh11.customLightTheme({})), + Provider: createThemeProvider( + createUnifiedTheme(rhdh11.customLightTheme({})), + ), }, { id: "rhdh-1.1-dark", title: "RHDH 1.1 Dark", variant: "dark", icon: , - Provider: createProvider(rhdh11.customDarkTheme({})), + Provider: createThemeProvider( + createUnifiedTheme(rhdh11.customDarkTheme({})), + ), }, { - id: "rhdh-latest-light", - title: "RHDH Light (latest)", + id: "rhdh-1.0-light", + title: "RHDH 1.0 Light", variant: "light", icon: , - Provider: createProvider(rhdh.customLightTheme({})), + Provider: createThemeProvider( + createUnifiedTheme(rhdh10.customLightTheme({})), + ), }, { - id: "rhdh-latest-dark", - title: "RHDH Dark (latest)", + id: "rhdh-1.0-dark", + title: "RHDH 1.0 Dark", variant: "dark", icon: , - Provider: createProvider(rhdh.customDarkTheme({})), + Provider: createThemeProvider( + createUnifiedTheme(rhdh10.customDarkTheme({})), + ), }, ]; }; -/** @deprecated use getAllThemes instead */ -export const createDevAppThemes = getAllThemes; - export const useAllThemes = (): AppTheme[] => { return React.useMemo(() => getAllThemes(), []); }; @@ -116,14 +174,14 @@ export const getThemes = (): AppTheme[] => { title: "Light", variant: "light", icon: , - Provider: createBrandedProvider("light", rhdh.customLightTheme), + Provider: createThemeProviderForThemeName("light"), }, { id: "dark", title: "Dark", variant: "dark", icon: , - Provider: createBrandedProvider("dark", rhdh.customDarkTheme), + Provider: createThemeProviderForThemeName("dark"), }, ]; }; @@ -135,11 +193,12 @@ export const useThemes = (): AppTheme[] => { export const useLoaderTheme = () => { return React.useMemo(() => { const latestTheme = localStorage.getItem("theme"); - const unifiedTheme = - latestTheme !== "dark" - ? rhdh.customLightTheme({}) - : rhdh.customDarkTheme({}); - const palette = unifiedTheme.getTheme("v5")?.palette; - return createTheme({ palette }); + const mode = latestTheme?.includes("dark") ? "dark" : "light"; + const variant = latestTheme?.includes("backstage") ? "backstage" : "rhdh"; + const themeOptions = + variant === "backstage" + ? backstage.getDefaultThemeConfig(mode) + : rhdh.getDefaultThemeConfig(mode); + return createTheme(themeOptions); }, []); }; diff --git a/src/themes/rhdh-1.0/darkTheme.ts b/src/themes/rhdh-1.0/darkTheme.ts index 639be5f..f85223a 100644 --- a/src/themes/rhdh-1.0/darkTheme.ts +++ b/src/themes/rhdh-1.0/darkTheme.ts @@ -1,29 +1,28 @@ -import { createUnifiedTheme, themes } from "@backstage/theme"; +import { themes } from "@backstage/theme"; import { components } from "./componentOverrides"; import { pageTheme } from "./pageTheme"; import { ThemeColors } from "./types"; -export const customDarkTheme = (themeColors: ThemeColors) => - createUnifiedTheme({ - palette: { - ...themes.dark.getTheme("v5")?.palette, - ...(themeColors.primaryColor && { - primary: { - ...themes.light.getTheme("v5")?.palette.primary, - main: themeColors.primaryColor, - }, - }), - navigation: { - background: "#0f1214", - indicator: themeColors.navigationIndicatorColor || "#009596", - color: "#ffffff", - selectedColor: "#ffffff", - navItem: { - hoverBackground: "#030303", - }, +export const customDarkTheme = (themeColors: ThemeColors) => ({ + palette: { + ...themes.dark.getTheme("v5")?.palette, + ...(themeColors.primaryColor && { + primary: { + ...themes.light.getTheme("v5")?.palette.primary, + main: themeColors.primaryColor, + }, + }), + navigation: { + background: "#0f1214", + indicator: themeColors.navigationIndicatorColor || "#009596", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#030303", }, }, - defaultPageTheme: "home", - pageTheme: pageTheme(themeColors), - components, - }); + }, + defaultPageTheme: "home", + pageTheme: pageTheme(themeColors), + components, +}); diff --git a/src/themes/rhdh-1.0/lightTheme.ts b/src/themes/rhdh-1.0/lightTheme.ts index 773071e..ce0eac1 100644 --- a/src/themes/rhdh-1.0/lightTheme.ts +++ b/src/themes/rhdh-1.0/lightTheme.ts @@ -1,29 +1,28 @@ -import { createUnifiedTheme, themes } from "@backstage/theme"; +import { themes } from "@backstage/theme"; import { components } from "./componentOverrides"; import { pageTheme } from "./pageTheme"; import { ThemeColors } from "./types"; -export const customLightTheme = (themeColors: ThemeColors) => - createUnifiedTheme({ - palette: { - ...themes.light.getTheme("v5")?.palette, - ...(themeColors.primaryColor && { - primary: { - ...themes.light.getTheme("v5")?.palette.primary, - main: themeColors.primaryColor, - }, - }), - navigation: { - background: "#222427", - indicator: themeColors.navigationIndicatorColor || "#009596", - color: "#ffffff", - selectedColor: "#ffffff", - navItem: { - hoverBackground: "#4f5255", - }, +export const customLightTheme = (themeColors: ThemeColors) => ({ + palette: { + ...themes.light.getTheme("v5")?.palette, + ...(themeColors.primaryColor && { + primary: { + ...themes.light.getTheme("v5")?.palette.primary, + main: themeColors.primaryColor, + }, + }), + navigation: { + background: "#222427", + indicator: themeColors.navigationIndicatorColor || "#009596", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#4f5255", }, }, - defaultPageTheme: "home", - pageTheme: pageTheme(themeColors), - components, - }); + }, + defaultPageTheme: "home", + pageTheme: pageTheme(themeColors), + components, +}); diff --git a/src/themes/rhdh-1.1/darkTheme.ts b/src/themes/rhdh-1.1/darkTheme.ts index ff54da9..d43de4b 100644 --- a/src/themes/rhdh-1.1/darkTheme.ts +++ b/src/themes/rhdh-1.1/darkTheme.ts @@ -1,29 +1,28 @@ -import { createUnifiedTheme, themes } from "@backstage/theme"; +import { themes } from "@backstage/theme"; import { components } from "./componentOverrides"; import { pageTheme } from "./pageTheme"; import { ThemeColors } from "./types"; -export const customDarkTheme = (themeColors: ThemeColors) => - createUnifiedTheme({ - palette: { - ...themes.dark.getTheme("v5")?.palette, - ...(themeColors.primaryColor && { - primary: { - ...themes.light.getTheme("v5")?.palette.primary, - main: themeColors.primaryColor, - }, - }), - navigation: { - background: "#0f1214", - indicator: themeColors.navigationIndicatorColor || "#0066CC", - color: "#ffffff", - selectedColor: "#ffffff", - navItem: { - hoverBackground: "#3c3f42", - }, +export const customDarkTheme = (themeColors: ThemeColors) => ({ + palette: { + ...themes.dark.getTheme("v5")?.palette, + ...(themeColors.primaryColor && { + primary: { + ...themes.light.getTheme("v5")?.palette.primary, + main: themeColors.primaryColor, + }, + }), + navigation: { + background: "#0f1214", + indicator: themeColors.navigationIndicatorColor || "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", }, }, - defaultPageTheme: "home", - pageTheme: pageTheme(themeColors), - components: components(themeColors, "dark"), - }); + }, + defaultPageTheme: "home", + pageTheme: pageTheme(themeColors), + components: components(themeColors, "dark"), +}); diff --git a/src/themes/rhdh-1.1/lightTheme.ts b/src/themes/rhdh-1.1/lightTheme.ts index fa1eb9e..3967a4c 100644 --- a/src/themes/rhdh-1.1/lightTheme.ts +++ b/src/themes/rhdh-1.1/lightTheme.ts @@ -1,33 +1,32 @@ -import { createUnifiedTheme, themes } from "@backstage/theme"; +import { themes } from "@backstage/theme"; import { components } from "./componentOverrides"; import { pageTheme } from "./pageTheme"; import { ThemeColors } from "./types"; -export const customLightTheme = (themeColors: ThemeColors) => - createUnifiedTheme({ - palette: { - ...themes.light.getTheme("v5")?.palette, - ...(themeColors.primaryColor && { - primary: { - ...themes.light.getTheme("v5")?.palette.primary, - main: themeColors.primaryColor, - }, - }), - navigation: { - background: "#222427", - indicator: themeColors.navigationIndicatorColor || "#0066CC", - color: "#ffffff", - selectedColor: "#ffffff", - navItem: { - hoverBackground: "#3c3f42", - }, +export const customLightTheme = (themeColors: ThemeColors) => ({ + palette: { + ...themes.light.getTheme("v5")?.palette, + ...(themeColors.primaryColor && { + primary: { + ...themes.light.getTheme("v5")?.palette.primary, + main: themeColors.primaryColor, }, - text: { - primary: "#151515", - secondary: "#757575", + }), + navigation: { + background: "#222427", + indicator: themeColors.navigationIndicatorColor || "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", }, }, - defaultPageTheme: "home", - pageTheme: pageTheme(themeColors), - components: components(themeColors, "light"), - }); + text: { + primary: "#151515", + secondary: "#757575", + }, + }, + defaultPageTheme: "home", + pageTheme: pageTheme(themeColors), + components: components(themeColors, "light"), +}); diff --git a/src/themes/rhdh-1.2.0/README.md b/src/themes/rhdh-1.2.0/README.md new file mode 100644 index 0000000..a1de084 --- /dev/null +++ b/src/themes/rhdh-1.2.0/README.md @@ -0,0 +1 @@ +Based on theme release 0.0.57, which was part of RHDH 1.2.0. diff --git a/src/themes/rhdh/componentOverrides.ts b/src/themes/rhdh-1.2.0/componentOverrides.ts similarity index 97% rename from src/themes/rhdh/componentOverrides.ts rename to src/themes/rhdh-1.2.0/componentOverrides.ts index 49d760e..4776743 100644 --- a/src/themes/rhdh/componentOverrides.ts +++ b/src/themes/rhdh-1.2.0/componentOverrides.ts @@ -86,16 +86,16 @@ export const components = ( }, containedPrimary: { backgroundColor: themePalette.primary.containedButtonBackground, - color: themePalette.general.contrastText, + color: themePalette.primary.contrastText, "&:hover": { backgroundColor: themePalette.primary.dark, - color: themePalette.general.contrastText, + color: themePalette.primary.contrastText, }, "&:focus-visible": { boxShadow: `inset 0 0 0 1px ${themePalette.general.focusVisibleBorder}`, outline: `${themePalette.general.focusVisibleBorder} solid 1px`, backgroundColor: themePalette.primary.dark, - color: themePalette.general.contrastText, + color: themePalette.primary.contrastText, }, "&:disabled": { color: themePalette.general.disabled, @@ -104,16 +104,16 @@ export const components = ( }, containedSecondary: { backgroundColor: themePalette.secondary.containedButtonBackground, - color: themePalette.general.contrastText, + color: themePalette.secondary.contrastText, "&:hover": { backgroundColor: themePalette.secondary.dark, - color: themePalette.general.contrastText, + color: themePalette.secondary.contrastText, }, "&:focus-visible": { boxShadow: `inset 0 0 0 1px ${themePalette.general.focusVisibleBorder}`, outline: `${themePalette.general.focusVisibleBorder} solid 1px`, backgroundColor: themePalette.secondary.dark, - color: themePalette.general.contrastText, + color: themePalette.secondary.contrastText, }, "&:disabled": { color: themePalette.general.disabled, @@ -353,7 +353,7 @@ export const components = ( textTransform: "unset !important", color: `${themePalette.general.tableColumnTitleColor} !important`, '& > span[class*="MuiTableSortLabel-active"]': { - color: `${themePalette.primary.main} !important`, + color: `${themePalette.general.tableColumnTitleActiveColor} !important`, }, '& > span > svg[class*="MuiTableSortLabel-icon"]': { color: "inherit !important", diff --git a/src/themes/rhdh-1.2.0/darkTheme.ts b/src/themes/rhdh-1.2.0/darkTheme.ts new file mode 100644 index 0000000..7378a12 --- /dev/null +++ b/src/themes/rhdh-1.2.0/darkTheme.ts @@ -0,0 +1,31 @@ +import { themes } from "@backstage/theme"; +import { components } from "./componentOverrides"; +import { pageTheme } from "./pageTheme"; +import { fonts, typography } from "./typography"; +import { ThemeColors } from "./types"; + +export const customDarkTheme = (themeColors: ThemeColors) => ({ + fontFamily: fonts.text, + typography, + palette: { + ...themes.dark.getTheme("v5")?.palette, + ...(themeColors.primaryColor && { + primary: { + ...themes.light.getTheme("v5")?.palette.primary, + main: themeColors.primaryColor, + }, + }), + navigation: { + background: "#0f1214", + indicator: themeColors.navigationIndicatorColor || "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", + }, + }, + }, + defaultPageTheme: "home", + pageTheme: pageTheme(themeColors), + components: components(themeColors, "dark"), +}); diff --git a/src/themes/rhdh/defaultThemePalette.ts b/src/themes/rhdh-1.2.0/defaultThemePalette.ts similarity index 53% rename from src/themes/rhdh/defaultThemePalette.ts rename to src/themes/rhdh-1.2.0/defaultThemePalette.ts index 3680160..dd93673 100644 --- a/src/themes/rhdh/defaultThemePalette.ts +++ b/src/themes/rhdh-1.2.0/defaultThemePalette.ts @@ -20,29 +20,25 @@ export const defaultThemePalette = (mode: string, themeColors: ThemeColors) => { tableTitleColor: "#E0E0E0", tableSubtitleColor: "#E0E0E0", tableColumnTitleColor: "#E0E0E0", + tableColumnTitleActiveColor: "#1FA7F8", tableRowHover: "#0f1214", tableBorderColor: "#515151", tableBackgroundColor: "#1b1d21", tabsBottomBorderColor: "#444548", - contrastText: "#FFF", }, primary: { - main: themeColors.primary?.main || "#1FA7F8", // text button color, button background color - containedButtonBackground: - themeColors.primary?.containedButtonBackground || "#0066CC", // contained button background color - textHover: themeColors.primary?.textHover || "#73BCF7", // text button hover color - focusVisibleBorder: - themeColors.primary?.focusVisibleBorder || "#ADD6FF", - dark: themeColors.primary?.dark || "#004080", // contained button hover background color + main: themeColors.primaryColor || "#1FA7F8", // text button color, button background color + containedButtonBackground: "#0066CC", // contained button background color + textHover: "#73BCF7", // text button hover color + contrastText: "#FFF", // contained button text color + dark: "#004080", // contained button hover background color }, secondary: { - main: themeColors.secondary?.main || "#B2A3FF", - containedButtonBackground: - themeColors.secondary?.containedButtonBackground || "#8476D1", - textHover: themeColors.secondary?.textHover || "#CBC1FF", - focusVisibleBorder: - themeColors.secondary?.focusVisibleBorder || "#D0C7FF", - dark: themeColors.secondary?.dark || "#6753AC", + main: "#B2A3FF", + containedButtonBackground: "#8476D1", + textHover: "#CBC1FF", + contrastText: "#FFF", + dark: "#6753AC", }, }; } @@ -51,6 +47,7 @@ export const defaultThemePalette = (mode: string, themeColors: ThemeColors) => { disabledBackground: "#D2D2D2", disabled: "#6A6E73", searchBarBorderColor: "#E4E4E4", + focusVisibleBorder: "#0066CC", formControlBackgroundColor: "#FFF", mainSectionBackgroundColor: "#FFF", headerBackgroundColor: "#FFF", @@ -63,28 +60,25 @@ export const defaultThemePalette = (mode: string, themeColors: ThemeColors) => { tableTitleColor: "#181818", tableSubtitleColor: "#616161", tableColumnTitleColor: "#151515", + tableColumnTitleActiveColor: "#0066CC", tableRowHover: "#F5F5F5", tableBorderColor: "#E0E0E0", tableBackgroundColor: "#FFF", tabsBottomBorderColor: "#D2D2D2", - contrastText: "#FFF", }, primary: { - main: themeColors.primary?.main || "#0066CC", - containedButtonBackground: - themeColors.primary?.containedButtonBackground || "#0066CC", - textHover: themeColors.primary?.textHover || "#004080", - focusVisibleBorder: themeColors.primary?.focusVisibleBorder || "#0066CC", - dark: themeColors.primary?.dark || "#004080", + main: themeColors.primaryColor || "#0066CC", + containedButtonBackground: "#0066CC", + mainHover: "#004080", + contrastText: "#FFF", + dark: "#004080", }, secondary: { - main: themeColors.secondary?.main || "#8476D1", - containedButtonBackground: - themeColors.secondary?.containedButtonBackground || "#8476D1", - textHover: themeColors.secondary?.textHover || "#6753AC", - focusVisibleBorder: - themeColors.secondary?.focusVisibleBorder || "#8476D1", - dark: themeColors.secondary?.dark || "#6753AC", + main: "#8476D1", + containedButtonBackground: "#8476D1", + mainHover: "#6753AC", + contrastText: "#FFF", + dark: "#6753AC", }, }; }; diff --git a/src/themes/rhdh-1.2.0/index.ts b/src/themes/rhdh-1.2.0/index.ts new file mode 100644 index 0000000..603b5db --- /dev/null +++ b/src/themes/rhdh-1.2.0/index.ts @@ -0,0 +1,5 @@ +// Keep ../../../src reference here so that the files are also found from the dist folder! +import "../../../src/fonts/font.css"; + +export { customLightTheme } from "./lightTheme"; +export { customDarkTheme } from "./darkTheme"; diff --git a/src/themes/rhdh-1.2.0/lightTheme.ts b/src/themes/rhdh-1.2.0/lightTheme.ts new file mode 100644 index 0000000..f6666ce --- /dev/null +++ b/src/themes/rhdh-1.2.0/lightTheme.ts @@ -0,0 +1,35 @@ +import { themes } from "@backstage/theme"; +import { components } from "./componentOverrides"; +import { pageTheme } from "./pageTheme"; +import { fonts, typography } from "./typography"; +import { ThemeColors } from "./types"; + +export const customLightTheme = (themeColors: ThemeColors) => ({ + fontFamily: fonts.text, + typography, + palette: { + ...themes.light.getTheme("v5")?.palette, + ...(themeColors.primaryColor && { + primary: { + ...themes.light.getTheme("v5")?.palette.primary, + main: themeColors.primaryColor, + }, + }), + navigation: { + background: "#222427", + indicator: themeColors.navigationIndicatorColor || "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", + }, + }, + text: { + primary: "#151515", + secondary: "#757575", + }, + }, + defaultPageTheme: "home", + pageTheme: pageTheme(themeColors), + components: components(themeColors, "light"), +}); diff --git a/src/themes/rhdh/pageTheme.ts b/src/themes/rhdh-1.2.0/pageTheme.ts similarity index 100% rename from src/themes/rhdh/pageTheme.ts rename to src/themes/rhdh-1.2.0/pageTheme.ts diff --git a/src/themes/rhdh-1.2.0/types.ts b/src/themes/rhdh-1.2.0/types.ts new file mode 100644 index 0000000..f062931 --- /dev/null +++ b/src/themes/rhdh-1.2.0/types.ts @@ -0,0 +1,6 @@ +export type ThemeColors = { + primaryColor?: string; + headerColor1?: string; + headerColor2?: string; + navigationIndicatorColor?: string; +}; diff --git a/src/themes/rhdh-1.2.0/typography (1).ts b/src/themes/rhdh-1.2.0/typography (1).ts new file mode 100644 index 0000000..6a784e0 --- /dev/null +++ b/src/themes/rhdh-1.2.0/typography (1).ts @@ -0,0 +1,76 @@ +import { BackstageTypography } from "@backstage/theme"; + +export const fonts = { + text: [ + "RedHatText", + '"Helvetica Neue"', + "-apple-system", + '"Segoe UI"', + "Roboto", + "Helvetica", + "Arial", + "sans-serif", + ].join(", "), + heading: [ + "RedHatDisplay", + '"Helvetica Neue"', + "-apple-system", + '"Segoe UI"', + "Roboto", + "Helvetica", + "Arial", + "sans-serif", + ].join(", "), + monospace: [ + "RedHatMono", + '"Liberation Mono"', + "consolas", + '"SFMono-Regular"', + "menlo", + "monaco", + '"Courier New"', + "monospace", + ].join(", "), +}; + +// Font sized based on https://www.patternfly.org/design-foundations/typography +export const typography: BackstageTypography = { + htmlFontSize: 16, + fontFamily: fonts.text, + h1: { + fontFamily: fonts.heading, + fontSize: 36, + fontWeight: 400, + marginBottom: 10, + }, + h2: { + fontFamily: fonts.heading, + fontSize: 28, + fontWeight: 400, + marginBottom: 8, + }, + h3: { + fontFamily: fonts.heading, + fontSize: 24, + fontWeight: 400, + marginBottom: 6, + }, + h4: { + fontFamily: fonts.heading, + fontSize: 20, + fontWeight: 400, + marginBottom: 6, + }, + h5: { + fontFamily: fonts.heading, + fontSize: 18, + fontWeight: 500, + marginBottom: 4, + }, + h6: { + fontFamily: fonts.heading, + fontSize: 16, + fontWeight: 500, + marginBottom: 2, + }, +}; diff --git a/src/themes/rhdh-1.2.0/typography.ts b/src/themes/rhdh-1.2.0/typography.ts new file mode 100644 index 0000000..6a784e0 --- /dev/null +++ b/src/themes/rhdh-1.2.0/typography.ts @@ -0,0 +1,76 @@ +import { BackstageTypography } from "@backstage/theme"; + +export const fonts = { + text: [ + "RedHatText", + '"Helvetica Neue"', + "-apple-system", + '"Segoe UI"', + "Roboto", + "Helvetica", + "Arial", + "sans-serif", + ].join(", "), + heading: [ + "RedHatDisplay", + '"Helvetica Neue"', + "-apple-system", + '"Segoe UI"', + "Roboto", + "Helvetica", + "Arial", + "sans-serif", + ].join(", "), + monospace: [ + "RedHatMono", + '"Liberation Mono"', + "consolas", + '"SFMono-Regular"', + "menlo", + "monaco", + '"Courier New"', + "monospace", + ].join(", "), +}; + +// Font sized based on https://www.patternfly.org/design-foundations/typography +export const typography: BackstageTypography = { + htmlFontSize: 16, + fontFamily: fonts.text, + h1: { + fontFamily: fonts.heading, + fontSize: 36, + fontWeight: 400, + marginBottom: 10, + }, + h2: { + fontFamily: fonts.heading, + fontSize: 28, + fontWeight: 400, + marginBottom: 8, + }, + h3: { + fontFamily: fonts.heading, + fontSize: 24, + fontWeight: 400, + marginBottom: 6, + }, + h4: { + fontFamily: fonts.heading, + fontSize: 20, + fontWeight: 400, + marginBottom: 6, + }, + h5: { + fontFamily: fonts.heading, + fontSize: 18, + fontWeight: 500, + marginBottom: 4, + }, + h6: { + fontFamily: fonts.heading, + fontSize: 16, + fontWeight: 500, + marginBottom: 2, + }, +}; diff --git a/src/themes/rhdh/README.md b/src/themes/rhdh/README.md deleted file mode 100644 index 51fe79a..0000000 --- a/src/themes/rhdh/README.md +++ /dev/null @@ -1,3 +0,0 @@ -Copied from https://github.com/janus-idp/backstage-showcase/tree/v1.2.0/packages/app/src/themes - -Copied from master, incl. https://github.com/janus-idp/backstage-showcase/pull/1000 diff --git a/src/themes/rhdh/components.test.ts b/src/themes/rhdh/components.test.ts new file mode 100644 index 0000000..938a17c --- /dev/null +++ b/src/themes/rhdh/components.test.ts @@ -0,0 +1,71 @@ +import { ThemeConfig } from "../../types"; +import { components, Components } from "./components"; + +interface TestCase { + name: string; + config: ThemeConfig; + expected: Components; +} + +const testCases: TestCase[] = [ + { + name: "No options defined", + config: {}, + expected: expect.objectContaining({ + MuiButton: { + defaultProps: { + disableRipple: true, + }, + styleOverrides: expect.any(Object), + }, + }), + }, + { + name: "No option parameters are defined", + config: { + options: {}, + }, + expected: expect.objectContaining({ + MuiButton: { + defaultProps: { + disableRipple: true, + }, + styleOverrides: expect.any(Object), + }, + }), + }, + { + name: "Reenable ripple effect when rippleEffect=on", + config: { + options: { + rippleEffect: "on", + }, + }, + expected: expect.objectContaining({ + MuiButton: { + defaultProps: { + disableRipple: false, + }, + styleOverrides: expect.any(Object), + }, + }), + }, + { + name: "No components returned for components=backstage", + config: { + options: { + components: "backstage", + }, + }, + expected: {}, + }, +]; + +describe("components", () => { + testCases.forEach((testCase) => { + it(testCase.name, () => { + const actual = components(testCase.config); + expect(actual).toEqual(testCase.expected); + }); + }); +}); diff --git a/src/themes/rhdh/components.ts b/src/themes/rhdh/components.ts new file mode 100644 index 0000000..1605e40 --- /dev/null +++ b/src/themes/rhdh/components.ts @@ -0,0 +1,691 @@ +import { type UnifiedThemeOptions } from "@backstage/theme"; +import { ThemeConfig, ThemeOptions, RHDHThemePalette } from "../../types"; + +export type Component = { + defaultProps?: unknown; + styleOverrides?: unknown; + variants?: unknown; +}; + +export type Components = UnifiedThemeOptions["components"] & { + BackstageHeaderTabs?: Component; + BackstageSidebar?: Component; + BackstageSidebarItem?: Component; + BackstagePage?: Component; + BackstageContent?: Component; + BackstageContentHeader?: Component; + BackstageHeader?: Component; + BackstageItemCardHeader?: Component; + BackstageTableToolbar?: Component; + CatalogReactUserListPicker?: Component; + PrivateTabIndicator?: Component; +}; + +export const components = (themeConfig: ThemeConfig): Components => { + // Short hands to ensure that the code doesn't break if one of the properties is not defined. + + const options = themeConfig.options ?? ({} as ThemeOptions); + const disableRipple = options?.rippleEffect !== "on"; // by default ripple effect is disabled + + const palette = themeConfig.palette ?? {}; + + const rhdh = palette.rhdh; + const general = rhdh?.general || ({} as RHDHThemePalette["general"]); + const rhdhPrimary = rhdh?.primary || ({} as RHDHThemePalette["primary"]); + const rhdhSecondary = + rhdh?.secondary || ({} as RHDHThemePalette["secondary"]); + + const components: Components = {}; + if (options.components === "backstage" || options.components === "mui") { + return components; + } + + // + // MUI components + // + + // MUI base + if (options.buttons !== "mui") { + components.MuiTypography = { + styleOverrides: { + button: { + textTransform: "none", + fontWeight: "bold", + }, + }, + }; + } + + // MUI container + if (options.paper !== "mui") { + components.MuiPaper = { + styleOverrides: { + root: { + boxShadow: "none", + backgroundColor: general.cardBackgroundColor, + // hide the first child element which is a divider with MuiDivider-root classname in MuiPaper + '& > hr:first-child[class|="MuiDivider-root"]': { + height: 0, + }, + }, + elevation0: { + '& div[class*="Mui-disabled"]': { + backgroundColor: "unset", + }, + '& span[class*="Mui-disabled"]': { + backgroundColor: "unset", + }, + '& input[class*="Mui-disabled"]': { + backgroundColor: "unset", + }, + }, + elevation1: { + boxShadow: "none", + borderRadius: "0", + outline: `1px solid ${general.cardBorderColor}`, + '& > hr[class|="MuiDivider-root"]': { + backgroundColor: general.cardBorderColor, + }, + }, + elevation2: { + backgroundColor: general.tableBackgroundColor, + boxShadow: "none", + outline: `1px solid ${general.cardBorderColor}`, + padding: "1rem", + }, + }, + }; + } + + // MUI buttons + // Don't disableRipple for MuiButtonBase as it will affect all the buttons + // and we need to ensure that the buttons have a right touch and focus styling. + if (options.buttons !== "mui") { + components.MuiButton = { + defaultProps: { + disableRipple, + }, + styleOverrides: { + root: { + textTransform: "none", + border: "0", + borderRadius: "3px", + }, + contained: { + boxShadow: "none", + "&:hover": { + border: "0", + boxShadow: "none", + }, + "&:-webkit-any-link:focus-visible": { + outlineOffset: "0", + }, + }, + containedPrimary: { + backgroundColor: rhdhPrimary.containedButtonBackground, + color: general.contrastText, + "&:hover": { + backgroundColor: rhdhPrimary.dark, + color: general.contrastText, + }, + "&:focus-visible": { + boxShadow: `inset 0 0 0 1px ${rhdhPrimary.focusVisibleBorder}`, + outline: `${rhdhPrimary.focusVisibleBorder} solid 1px`, + backgroundColor: rhdhPrimary.dark, + color: general.contrastText, + }, + "&:disabled": { + color: general.disabled, + backgroundColor: general.disabledBackground, + }, + }, + containedSecondary: { + "&:hover": { + backgroundColor: rhdhSecondary.dark, + color: general.contrastText, + }, + "&:focus-visible": { + boxShadow: `inset 0 0 0 1px ${rhdhSecondary.focusVisibleBorder}`, + outline: `${rhdhSecondary.focusVisibleBorder} solid 1px`, + backgroundColor: rhdhSecondary.dark, + color: general.contrastText, + }, + "&:disabled": { + color: general.disabled, + backgroundColor: general.disabledBackground, + }, + }, + outlined: { + border: "0", + boxShadow: `inset 0 0 0 1px ${rhdhPrimary.main}`, + "&:hover": { + border: "0", + boxShadow: `inset 0 0 0 2px ${rhdhPrimary.main}`, + backgroundColor: "transparent", + }, + "&:focus-visible": { + boxShadow: `inset 0 0 0 2px ${rhdhPrimary.main}`, + outline: `${rhdhPrimary.focusVisibleBorder} solid 1px`, + }, + }, + outlinedPrimary: { + color: rhdhPrimary.main, + boxShadow: `inset 0 0 0 1px ${rhdhPrimary.main}`, + border: "0", + "&:hover": { + border: "0", + boxShadow: `inset 0 0 0 2px ${rhdhPrimary.main}`, + backgroundColor: "transparent", + }, + "&:focus-visible": { + boxShadow: `inset 0 0 0 2px ${rhdhPrimary.main}`, + outline: `${rhdhPrimary.focusVisibleBorder} solid 1px`, + }, + }, + outlinedSecondary: { + color: rhdhSecondary.main, + boxShadow: `inset 0 0 0 1px ${rhdhSecondary.main}`, + border: "0", + "&:hover": { + border: "0", + boxShadow: `inset 0 0 0 2px ${rhdhSecondary.main}`, + backgroundColor: "transparent", + }, + "&:focus-visible": { + boxShadow: `inset 0 0 0 2px ${rhdhSecondary.main}`, + outline: `${rhdhSecondary.focusVisibleBorder} solid 1px`, + }, + }, + text: { + color: rhdhPrimary.main, + "&:hover": { + color: rhdhPrimary.textHover, + backgroundColor: "transparent", + }, + "&:focus-visible": { + boxShadow: `inset 0 0 0 2px ${rhdhPrimary.main}`, + outline: `${rhdhPrimary.focusVisibleBorder} solid 1px`, + }, + }, + textPrimary: { + color: rhdhPrimary.main, + "&:hover": { + color: rhdhPrimary.textHover, + backgroundColor: "transparent", + }, + }, + textSecondary: { + color: rhdhSecondary.main, + "&:hover": { + color: rhdhSecondary.textHover, + backgroundColor: "transparent", + }, + }, + }, + }; + components.MuiToggleButton = { + styleOverrides: { + root: { + textTransform: "none", + }, + }, + }; + components.MuiIconButton = { + styleOverrides: { + root: { + "&:disabled": { + color: general.disabled, + }, + }, + }, + }; + components.MuiLink = { + styleOverrides: { + underlineHover: { + "&:hover": { + textDecoration: "none", + }, + }, + }, + }; + } + + // MUI input fields + if (options.inputs !== "mui") { + components.MuiInputBase = { + styleOverrides: { + input: { + "&::placeholder": { + color: general.disabled, + opacity: 1, + }, + }, + }, + }; + components.MuiOutlinedInput = { + styleOverrides: { + input: { + "&:autofill": { + boxShadow: `0 0 0 100px ${general.formControlBackgroundColor} inset`, + borderRadius: "unset", + }, + }, + }, + }; + } + + // MUI accordion + if (options.accordions !== "mui") { + components.MuiAccordion = { + styleOverrides: { + root: { + boxShadow: "none", + }, + rounded: { + "&:first-child": { + borderTopLeftRadius: "0", + borderTopRightRadius: "0", + }, + "&:last-child": { + borderBottomLeftRadius: "0", + borderBottomRightRadius: "0", + }, + }, + }, + }; + } + + // MUI cards + if (options.cards !== "mui") { + components.MuiCard = { + styleOverrides: { + root: { + display: "flex", + flexDirection: "column", + backgroundColor: general.cardBackgroundColor, + }, + }, + }; + components.MuiCardHeader = { + styleOverrides: { + content: { + "& > span > nav span": { + textTransform: "unset", + letterSpacing: "normal", + fountweight: "normal", + }, + }, + title: { + fontSize: "1.125rem", + }, + action: { + "& > a > span > svg": { + fontSize: "1.125rem", + }, + '& > a[class*="MuiIconButton-root"]:hover': { + color: rhdhPrimary.textHover, + backgroundColor: "transparent", + }, + }, + }, + }; + components.MuiCardContent = { + styleOverrides: { + root: { + flexGrow: "1", + backgroundColor: general.cardBackgroundColor, + '& > div > div > h2[class*="MuiTypography-h2-"]': { + textTransform: "unset", + color: general.cardSubtitleColor, + opacity: "40%", + }, + '& > div > div > div[class*="MuiChip-sizeSmall"]': { + backgroundColor: "transparent", + borderRadius: "8px", + outline: `1px solid ${general.disabled}`, + }, + '& > div[class*="Mui-expanded"]': { + margin: "auto", + }, + '& > div[class*="MuiAccordion-root"]:before': { + height: 0, + }, + // Override the default line-clamp from 10 to 2 for the Software template catalog + '& > div[class*="MuiGrid-root-"][class*="MuiGrid-container-"][class*="MuiGrid-spacing-xs-2-"] > div[class*="MuiGrid-root-"][class*="MuiGrid-item-"][class*="MuiGrid-grid-xs-12-"] > div[class*="MuiBox-root-"]': + { + "-webkit-line-clamp": "2", + }, + }, + }, + }; + } + + // MUI table + if (options.tables !== "mui") { + components.MuiTable = { + styleOverrides: { + root: { + backgroundColor: general.tableBackgroundColor, + }, + }, + }; + components.MuiTableRow = { + styleOverrides: { + root: { + backgroundColor: general.tableBackgroundColor, + '&:not([class*="MuiTableRow-footer"]):hover': { + backgroundColor: `${general.tableRowHover} !important`, + }, + '& > th[class*="MuiTableCell-head"]': { + backgroundColor: general.tableBackgroundColor, + }, + }, + }, + }; + components.MuiTableCell = { + styleOverrides: { + root: { + textTransform: "none", + '&[class*="BackstageTableHeader-header"]': { + borderTop: "unset", + borderBottom: `1px solid ${general.tableBorderColor}`, + }, + }, + // @ts-expect-error: MUI types are not up to date + head: { + textTransform: "unset !important", + color: `${general.tableColumnTitleColor} !important`, + '& > span[class*="MuiTableSortLabel-active"]': { + color: `${rhdhPrimary.main} !important`, + }, + '& > span > svg[class*="MuiTableSortLabel-icon"]': { + color: "inherit !important", + }, + }, + body: { + color: general.tableTitleColor, + '& > div[class*="MuiChip-sizeSmall"]': { + margin: "2px", + }, + }, + }, + }; + components.MuiTableFooter = { + styleOverrides: { + root: { + "& > tr > td": { + borderBottom: "none", + }, + }, + }, + }; + } + + // MUI toolbar + if (options.toolbars !== "mui") { + components.MuiToolbar = { + styleOverrides: { + regular: { + '& > div > h2[class*="MuiTypography-h5"]': { + fontSize: "1.25rem", + color: general.tableTitleColor, + }, + }, + }, + }; + } + + // MUI dialogs + if (options.dialogs !== "mui") { + components.MuiDialogContent = { + styleOverrides: { + root: { + "& > div": { + backgroundColor: general.cardBackgroundColor, + }, + }, + }, + }; + } + + // MUI tabs + if (options.tabs !== "mui") { + components.MuiTabs = { + defaultProps: { + TabIndicatorProps: { + style: { + background: rhdhPrimary.main, + }, + }, + }, + styleOverrides: { + root: { + boxShadow: `0 -1px ${general.tabsBottomBorderColor} inset`, + padding: "0 1.5rem", + }, + flexContainerVertical: { + "& > button:hover": { + boxShadow: `-3px 0 ${general.tabsBottomBorderColor} inset`, + }, + }, + }, + }; + components.MuiTab = { + defaultProps: { + disableRipple, + }, + styleOverrides: { + root: { + textTransform: "none", + minWidth: "initial !important", + "&.Mui-disabled": { + backgroundColor: general.disabledBackground, + }, + }, + }, + }; + } + + // + // Backstage + // + if (options.tabs !== "mui") { + components.BackstageHeaderTabs = { + styleOverrides: { + tabsWrapper: { + paddingLeft: "0", + backgroundColor: general.mainSectionBackgroundColor, + }, + defaultTab: { + textTransform: "none", + fontSize: "1rem", + fontWeight: "500", + color: general.disabled, + padding: "0.5rem 1rem", + "&:hover": { + boxShadow: `0 -3px ${general.tabsBottomBorderColor} inset`, + }, + }, + tabRoot: { + "&:hover": { + backgroundColor: "unset", + }, + "&:not(.Mui-selected):hover": { + color: general.disabled, + }, + }, + }, + }; + } + + if (options.sidebars !== "mui") { + components.BackstageSidebar = { + styleOverrides: { + drawer: { + backgroundColor: general.sideBarBackgroundColor, + }, + }, + }; + components.BackstageSidebarItem = { + styleOverrides: { + label: { + '&[class*="MuiTypography-subtitle2"]': { + fontWeight: "500", + }, + }, + }, + }; + } + + if (options.pages !== "mui") { + components.BackstagePage = { + styleOverrides: { + root: { + backgroundColor: general.mainSectionBackgroundColor, + }, + }, + }; + components.BackstageContent = { + styleOverrides: { + root: { + backgroundColor: general.mainSectionBackgroundColor, + "& div:first-child": { + '& > div[class*="-searchBar"]': { + backgroundColor: general.formControlBackgroundColor, + border: `1px solid ${general.searchBarBorderColor}`, + boxShadow: "none", + }, + }, + }, + }, + }; + components.BackstageContentHeader = { + styleOverrides: { + leftItemsBox: { + '& > h2[class*="BackstageContentHeader-title-"][class*="MuiTypography-h4-"]': + { + fontWeight: "bold", + fontSize: "1.75rem", + }, + }, + }, + }; + } + + if (options.headers !== "mui") { + components.BackstageHeader = { + styleOverrides: { + header: { + // color: general.headerTextColor, + // backgroundImage: `none, linear-gradient(90deg, ${headerColor1}, ${headerColor2})`, + // backgroundColor: general.headerBackgroundColor, + boxShadow: "none", + borderBottom: `1px solid ${general.headerBottomBorderColor}`, + }, + title: { + color: general.cardSubtitleColor, + fontWeight: "bold", + '&[class*="MuiTypography-h1-"]': { + fontWeight: "bold", + fontSize: "2rem", + }, + }, + leftItemsBox: { + color: general.headerTextColor, + "& > nav": { + color: general.headerTextColor, + }, + "& > p": { + color: general.headerTextColor, + }, + "& > span": { + color: general.headerTextColor, + }, + }, + rightItemsBox: { + color: general.headerTextColor, + "& div": { + color: general.headerTextColor, + }, + "& p": { + color: general.headerTextColor, + }, + "& a": { + color: general.headerTextColor, + }, + "& button": { + color: general.headerTextColor, + }, + }, + }, + }; + components.BackstageItemCardHeader = { + styleOverrides: { + root: { + '&[class*="MuiBox-root-"]': { + color: general.headerTextColor, + backgroundImage: "none", + backgroundColor: general.headerBackgroundColor, + borderBottom: `1px solid ${general.cardBorderColor}`, + }, + '& > h3[class*="MuiTypography-subtitle2-"] > div > div:first-child': { + color: general.tableSubtitleColor, + textTransform: "capitalize", + }, + '& > h3[class*="MuiTypography-subtitle2-"] > div > div:last-child': { + color: general.cardSubtitleColor, + }, + '& > h4[class*="MuiTypography-h6-"]': { + color: general.cardSubtitleColor, + }, + }, + }, + }; + } + + if (options.toolbars !== "mui") { + components.BackstageTableToolbar = { + styleOverrides: { + root: { + "& h2": { + fontWeight: "bold", + }, + }, + title: { + "& > h2": { + fontWeight: "bold", + }, + }, + }, + }; + } + + // + // Others + // + components.CatalogReactUserListPicker = { + styleOverrides: { + root: { + borderRadius: "4px", + }, + title: { + textTransform: "none", + }, + }, + }; + + if (options.tabs !== "mui") { + components.PrivateTabIndicator = { + styleOverrides: { + root: { + height: "3px", + }, + vertical: { + width: "3px", + }, + }, + }; + } + + return components; +}; diff --git a/src/themes/rhdh/darkTheme.test.ts b/src/themes/rhdh/darkTheme.test.ts new file mode 100644 index 0000000..cbc1729 --- /dev/null +++ b/src/themes/rhdh/darkTheme.test.ts @@ -0,0 +1,120 @@ +import { customDarkTheme } from "./darkTheme"; + +describe("customDarkTheme", () => { + it("should return the correct defaults for dark mode", () => { + expect(customDarkTheme()).toEqual({ + background: { + default: "#333333", + paper: "#424242", + }, + banner: { + closeButtonColor: "#FFFFFF", + error: "#E22134", + info: "#2E77D0", + link: "#000000", + text: "#FFFFFF", + warning: "#FF9800", + }, + border: "#E6E6E6", + bursts: { + backgroundColor: { + default: "#7C3699", + }, + fontColor: "#FEFEFE", + gradient: { + linear: "linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)", + }, + slackChannelText: "#ddd", + }, + errorBackground: "#FFEBEE", + errorText: "#CA001B", + gold: "#FFD600", + highlight: "#FFFBCC", + infoBackground: "#ebf5ff", + infoText: "#004e8a", + link: "#9CC9FF", + linkHover: "#82BAFD", + mode: "dark", + navigation: { + background: "#0f1214", + indicator: "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", + }, + submenu: { + background: "#0f1214", + }, + }, + pinSidebarButton: { + background: "#BDBDBD", + icon: "#404040", + }, + primary: { + main: "#1FA7F8", + dark: "#004080", + }, + secondary: { + main: "#B2A3FF", + dark: "#6753AC", + }, + status: { + aborted: "#9E9E9E", + error: "#F84C55", + ok: "#71CF88", + pending: "#FEF071", + running: "#3488E3", + warning: "#FFB84D", + }, + tabbar: { + indicator: "#9BF0E1", + }, + textContrast: "#FFFFFF", + textSubtle: "#CCCCCC", + textVerySubtle: "#727272", + type: "dark", + warningBackground: "#F59B23", + warningText: "#000000", + + rhdh: { + general: { + disabledBackground: "#444548", + disabled: "#AAABAC", + searchBarBorderColor: "#57585a", + formControlBackgroundColor: "#36373A", + mainSectionBackgroundColor: "#0f1214", + headerBackgroundColor: "#0f1214", + headerTextColor: "#FFF", + headerBottomBorderColor: "#A3A3A3", + cardBackgroundColor: "#292929", + sideBarBackgroundColor: "#1b1d21", + cardSubtitleColor: "#FFF", + cardBorderColor: "#A3A3A3", + tableTitleColor: "#E0E0E0", + tableSubtitleColor: "#E0E0E0", + tableColumnTitleColor: "#E0E0E0", + tableRowHover: "#0f1214", + tableBorderColor: "#515151", + tableBackgroundColor: "#1b1d21", + tabsBottomBorderColor: "#444548", + contrastText: "#FFF", + }, + primary: { + main: "#1FA7F8", + containedButtonBackground: "#0066CC", + textHover: "#73BCF7", + focusVisibleBorder: "#ADD6FF", + dark: "#004080", + }, + secondary: { + main: "#B2A3FF", + containedButtonBackground: "#8476D1", + textHover: "#CBC1FF", + focusVisibleBorder: "#D0C7FF", + dark: "#6753AC", + }, + }, + }); + }); +}); diff --git a/src/themes/rhdh/darkTheme.ts b/src/themes/rhdh/darkTheme.ts index f2d004e..0cb5c54 100644 --- a/src/themes/rhdh/darkTheme.ts +++ b/src/themes/rhdh/darkTheme.ts @@ -1,32 +1,71 @@ -import { createUnifiedTheme, themes } from "@backstage/theme"; -import { components } from "./componentOverrides"; -import { pageTheme } from "./pageTheme"; -import { fonts, typography } from "./typography"; -import { ThemeColors } from "./types"; +import { palettes } from "@backstage/theme"; +import { type PaletteOptions } from "@mui/material"; +import { type ThemeConfigPalette } from "../../types"; -export const customDarkTheme = (themeColors: ThemeColors) => - createUnifiedTheme({ - fontFamily: fonts.text, - typography, - palette: { - ...themes.dark.getTheme("v5")?.palette, - ...(themeColors.primary?.main && { - primary: { - ...themes.light.getTheme("v5")?.palette.primary, - main: themeColors.primary?.main, - }, - }), - navigation: { +export const customDarkTheme = (): ThemeConfigPalette => { + const palette: (typeof palettes)["dark"] & PaletteOptions = palettes.dark; + return { + ...palette, + primary: { + ...palette.primary, + main: "#1FA7F8", + dark: "#004080", + }, + secondary: { + ...palette.secondary, + main: "#B2A3FF", + dark: "#6753AC", + }, + navigation: { + ...palette.navigation, + background: "#0f1214", + indicator: "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", + }, + submenu: { background: "#0f1214", - indicator: themeColors.navigationIndicatorColor || "#0066CC", - color: "#ffffff", - selectedColor: "#ffffff", - navItem: { - hoverBackground: "#3c3f42", - }, }, }, - defaultPageTheme: "home", - pageTheme: pageTheme(themeColors), - components: components(themeColors, "dark"), - }); + rhdh: { + general: { + disabledBackground: "#444548", + disabled: "#AAABAC", + searchBarBorderColor: "#57585a", + formControlBackgroundColor: "#36373A", + mainSectionBackgroundColor: "#0f1214", + headerBackgroundColor: "#0f1214", + headerTextColor: "#FFF", + headerBottomBorderColor: "#A3A3A3", + cardBackgroundColor: "#292929", + sideBarBackgroundColor: "#1b1d21", + cardSubtitleColor: "#FFF", + cardBorderColor: "#A3A3A3", + tableTitleColor: "#E0E0E0", + tableSubtitleColor: "#E0E0E0", + tableColumnTitleColor: "#E0E0E0", + tableRowHover: "#0f1214", + tableBorderColor: "#515151", + tableBackgroundColor: "#1b1d21", + tabsBottomBorderColor: "#444548", + contrastText: "#FFF", + }, + primary: { + main: "#1FA7F8", + containedButtonBackground: "#0066CC", + textHover: "#73BCF7", + focusVisibleBorder: "#ADD6FF", + dark: "#004080", + }, + secondary: { + main: "#B2A3FF", + containedButtonBackground: "#8476D1", + textHover: "#CBC1FF", + focusVisibleBorder: "#D0C7FF", + dark: "#6753AC", + }, + }, + }; +}; diff --git a/src/themes/rhdh/index.ts b/src/themes/rhdh/index.ts index 603b5db..bb551d6 100644 --- a/src/themes/rhdh/index.ts +++ b/src/themes/rhdh/index.ts @@ -1,5 +1,28 @@ // Keep ../../../src reference here so that the files are also found from the dist folder! import "../../../src/fonts/font.css"; -export { customLightTheme } from "./lightTheme"; -export { customDarkTheme } from "./darkTheme"; +import { ThemeConfig } from "../../types"; +import { customDarkTheme } from "./darkTheme"; +import { customLightTheme } from "./lightTheme"; +import { fonts, typography } from "./typography"; + +export const getDefaultThemeConfig = (mode: "light" | "dark"): ThemeConfig => { + const palette = mode === "dark" ? customDarkTheme() : customLightTheme(); + + return { + variant: "rhdh", + mode: mode === "dark" ? "dark" : "light", + palette, + fontFamily: fonts.text, + typography, + defaultPageTheme: "default", + pageTheme: { + default: { + backgroundColor: mode === "dark" ? "#0f1214" : "#ffffff", + }, + }, + options: {}, + }; +}; + +export { components } from "./components"; diff --git a/src/themes/rhdh/lightTheme.test.ts b/src/themes/rhdh/lightTheme.test.ts new file mode 100644 index 0000000..180c6ae --- /dev/null +++ b/src/themes/rhdh/lightTheme.test.ts @@ -0,0 +1,124 @@ +import { customLightTheme } from "./lightTheme"; + +describe("customLightTheme", () => { + it("should return the correct defaults for light mode", () => { + expect(customLightTheme()).toEqual({ + background: { + default: "#F8F8F8", + paper: "#FFFFFF", + }, + banner: { + closeButtonColor: "#FFFFFF", + error: "#E22134", + info: "#2E77D0", + link: "#000000", + text: "#FFFFFF", + warning: "#FF9800", + }, + border: "#E6E6E6", + bursts: { + backgroundColor: { + default: "#7C3699", + }, + fontColor: "#FEFEFE", + gradient: { + linear: "linear-gradient(-137deg, #4BB8A5 0%, #187656 100%)", + }, + slackChannelText: "#ddd", + }, + errorBackground: "#FFEBEE", + errorText: "#CA001B", + gold: "#FFD600", + highlight: "#FFFBCC", + infoBackground: "#ebf5ff", + infoText: "#004e8a", + link: "#0A6EBE", + linkHover: "#2196F3", + mode: "light", + navigation: { + background: "#222427", + indicator: "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", + }, + submenu: { + background: "#222427", + }, + }, + pinSidebarButton: { + background: "#BDBDBD", + icon: "#181818", + }, + primary: { + main: "#0066CC", + dark: "#004080", + }, + secondary: { + main: "#8476D1", + dark: "#6753AC", + }, + status: { + aborted: "#757575", + error: "#E22134", + ok: "#1DB954", + pending: "#FFED51", + running: "#1F5493", + warning: "#FF9800", + }, + tabbar: { + indicator: "#9BF0E1", + }, + textContrast: "#000000", + textSubtle: "#6E6E6E", + textVerySubtle: "#DDD", + type: "light", + warningBackground: "#F59B23", + warningText: "#000000", + + text: { + primary: "#151515", + secondary: "#757575", + }, + rhdh: { + general: { + disabledBackground: "#D2D2D2", + disabled: "#6A6E73", + searchBarBorderColor: "#E4E4E4", + formControlBackgroundColor: "#FFF", + mainSectionBackgroundColor: "#FFF", + headerBackgroundColor: "#FFF", + headerTextColor: "#151515", + headerBottomBorderColor: "#C7C7C7", + cardBackgroundColor: "#FFF", + sideBarBackgroundColor: "#212427", + cardSubtitleColor: "#000", + cardBorderColor: "#C7C7C7", + tableTitleColor: "#181818", + tableSubtitleColor: "#616161", + tableColumnTitleColor: "#151515", + tableRowHover: "#F5F5F5", + tableBorderColor: "#E0E0E0", + tableBackgroundColor: "#FFF", + tabsBottomBorderColor: "#D2D2D2", + contrastText: "#FFF", + }, + primary: { + main: "#0066CC", + containedButtonBackground: "#0066CC", + textHover: "#004080", + focusVisibleBorder: "#0066CC", + dark: "#004080", + }, + secondary: { + main: "#8476D1", + containedButtonBackground: "#8476D1", + textHover: "#6753AC", + focusVisibleBorder: "#8476D1", + dark: "#6753AC", + }, + }, + }); + }); +}); diff --git a/src/themes/rhdh/lightTheme.ts b/src/themes/rhdh/lightTheme.ts index ebb9cc1..8ee650d 100644 --- a/src/themes/rhdh/lightTheme.ts +++ b/src/themes/rhdh/lightTheme.ts @@ -1,36 +1,75 @@ -import { createUnifiedTheme, themes } from "@backstage/theme"; -import { components } from "./componentOverrides"; -import { pageTheme } from "./pageTheme"; -import { fonts, typography } from "./typography"; -import { ThemeColors } from "./types"; +import { palettes } from "@backstage/theme"; +import { type PaletteOptions } from "@mui/material"; +import { type ThemeConfigPalette } from "../../types"; -export const customLightTheme = (themeColors: ThemeColors) => - createUnifiedTheme({ - fontFamily: fonts.text, - typography, - palette: { - ...themes.light.getTheme("v5")?.palette, - ...(themeColors.primary?.main && { - primary: { - ...themes.light.getTheme("v5")?.palette.primary, - main: themeColors.primary?.main, - }, - }), - navigation: { +export const customLightTheme = (): ThemeConfigPalette => { + const palette: (typeof palettes)["light"] & PaletteOptions = palettes.light; + return { + ...palette, + primary: { + ...palette.primary, + main: "#0066CC", + dark: "#004080", + }, + secondary: { + ...palette.secondary, + main: "#8476D1", + dark: "#6753AC", + }, + navigation: { + ...palette.navigation, + background: "#222427", + indicator: "#0066CC", + color: "#ffffff", + selectedColor: "#ffffff", + navItem: { + hoverBackground: "#3c3f42", + }, + submenu: { background: "#222427", - indicator: themeColors.navigationIndicatorColor || "#0066CC", - color: "#ffffff", - selectedColor: "#ffffff", - navItem: { - hoverBackground: "#3c3f42", - }, }, - text: { - primary: "#151515", - secondary: "#757575", + }, + text: { + primary: "#151515", + secondary: "#757575", + }, + rhdh: { + general: { + disabledBackground: "#D2D2D2", + disabled: "#6A6E73", + searchBarBorderColor: "#E4E4E4", + formControlBackgroundColor: "#FFF", + mainSectionBackgroundColor: "#FFF", + headerBackgroundColor: "#FFF", + headerTextColor: "#151515", + headerBottomBorderColor: "#C7C7C7", + cardBackgroundColor: "#FFF", + sideBarBackgroundColor: "#212427", + cardSubtitleColor: "#000", + cardBorderColor: "#C7C7C7", + tableTitleColor: "#181818", + tableSubtitleColor: "#616161", + tableColumnTitleColor: "#151515", + tableRowHover: "#F5F5F5", + tableBorderColor: "#E0E0E0", + tableBackgroundColor: "#FFF", + tabsBottomBorderColor: "#D2D2D2", + contrastText: "#FFF", + }, + primary: { + main: "#0066CC", + containedButtonBackground: "#0066CC", + textHover: "#004080", + focusVisibleBorder: "#0066CC", + dark: "#004080", + }, + secondary: { + main: "#8476D1", + containedButtonBackground: "#8476D1", + textHover: "#6753AC", + focusVisibleBorder: "#8476D1", + dark: "#6753AC", }, }, - defaultPageTheme: "home", - pageTheme: pageTheme(themeColors), - components: components(themeColors, "light"), - }); + }; +}; diff --git a/src/themes/rhdh/types.ts b/src/themes/rhdh/types.ts deleted file mode 100644 index a831aa5..0000000 --- a/src/themes/rhdh/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type ThemeColors = { - headerColor1?: string; - headerColor2?: string; - navigationIndicatorColor?: string; - general?: { - [key: string]: string | undefined; - }; - primary?: { - [key: string]: string | undefined; - }; - secondary?: { - [key: string]: string | undefined; - }; -}; diff --git a/src/themes/useBrandingThemeColors.test.ts b/src/themes/useBrandingThemeColors.test.ts deleted file mode 100644 index 3e3006b..0000000 --- a/src/themes/useBrandingThemeColors.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { renderHook } from "@testing-library/react-hooks"; -import { useApi } from "@backstage/core-plugin-api"; -import { useBrandingThemeColors } from "./useBrandingThemeColors"; - -jest.mock("@backstage/core-plugin-api", () => ({ - ...jest.requireActual("@backstage/core-plugin-api"), - useApi: jest.fn(), -})); - -describe("useBrandingThemeColors", () => { - it("returns the themeColors when config for them is available", () => { - (useApi as jest.Mock).mockReturnValue({ - getOptionalString: jest.fn().mockImplementation((key) => { - switch (key) { - // case "app.branding.theme.someTheme.primaryColor": - // return "blue"; - case "app.branding.theme.someTheme.headerColor1": - return "red"; - case "app.branding.theme.someTheme.headerColor2": - return "yellow"; - case "app.branding.theme.someTheme.navigationIndicatorColor": - return "purple"; - default: - return ""; - } - }), - }); - const { result } = renderHook(() => useBrandingThemeColors("someTheme")); - // expect(result.current.primaryColor).toBe("blue"); - expect(result.current.headerColor1).toBe("red"); - expect(result.current.headerColor2).toBe("yellow"); - expect(result.current.navigationIndicatorColor).toBe("purple"); - }); - - it("returns undefined when config is unavailable", () => { - // Mock the useApi function to throw an error (simulate unavailable config) - (useApi as jest.Mock).mockImplementation( - jest.fn(() => { - throw new Error("Custom hook error"); - }), - ); - - // const { result } = renderHook(() => useBrandingThemeColors("someTheme")); - // expect(result.current.primaryColor).toBeUndefined(); - }); -}); diff --git a/src/themes/useBrandingThemeColors.ts b/src/themes/useBrandingThemeColors.ts deleted file mode 100644 index 0f81f5f..0000000 --- a/src/themes/useBrandingThemeColors.ts +++ /dev/null @@ -1,67 +0,0 @@ -import React from "react"; -import { ConfigApi, configApiRef, useApi } from "@backstage/core-plugin-api"; - -type PrimaryColors = { - [key: string]: string | undefined; -}; - -type SecondaryColors = { - [key: string]: string | undefined; -}; - -export type BrandingThemeColors = { - primary?: PrimaryColors; - secondary?: SecondaryColors; - headerColor1?: string; - headerColor2?: string; - navigationIndicatorColor?: string; -}; - -const colorKeys = [ - "main", - "containedButtonBackground", - "textHover", - "focusVisibleBorder", - "dark", -]; - -export const useBrandingThemeColors = ( - themeName: string, -): BrandingThemeColors => { - let configApi: ConfigApi | undefined = undefined; - try { - configApi = useApi(configApiRef); - } catch (err) { - // useApi won't be initialized initially in createApp theme provider, and will get updated later - } - return React.useMemo(() => { - const brandingThemeColors: BrandingThemeColors = {}; - if (configApi) { - brandingThemeColors.headerColor1 = configApi.getOptionalString( - `app.branding.theme.${themeName}.headerColor1`, - ); - brandingThemeColors.headerColor2 = configApi.getOptionalString( - `app.branding.theme.${themeName}.headerColor2`, - ); - brandingThemeColors.navigationIndicatorColor = - configApi.getOptionalString( - `app.branding.theme.${themeName}.navigationIndicatorColor`, - ); - - brandingThemeColors.primary = colorKeys.reduce((acc, key) => { - acc[key] = configApi.getOptionalString( - `app.branding.theme.${themeName}.primary.${key}`, - ); - return acc; - }, {} as PrimaryColors); - - brandingThemeColors.secondary = colorKeys.reduce((acc, key) => { - acc[key] = configApi.getOptionalString( - `app.branding.theme.${themeName}.secondary.${key}`, - ); - return acc; - }, {} as SecondaryColors); - } - return brandingThemeColors; - }, [configApi]); -}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f574956 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,125 @@ +import { type UnifiedThemeOptions } from "@backstage/theme"; + +export type BackstageThemePalette = UnifiedThemeOptions["palette"]; + +export interface RHDHThemePalette { + general: { + disabledBackground: string; + disabled: string; + searchBarBorderColor: string; + formControlBackgroundColor: string; + mainSectionBackgroundColor: string; + headerBackgroundColor: string; + headerTextColor: string; + headerBottomBorderColor: string; + cardBackgroundColor: string; + sideBarBackgroundColor: string; + cardSubtitleColor: string; + cardBorderColor: string; + tableTitleColor: string; + tableSubtitleColor: string; + tableColumnTitleColor: string; + tableRowHover: string; + tableBorderColor: string; + tableBackgroundColor: string; + tabsBottomBorderColor: string; + contrastText: string; + }; + + primary: { + main: string; + containedButtonBackground: string; + textHover: string; + focusVisibleBorder: string; + dark: string; + }; + + secondary: { + main: string; + containedButtonBackground: string; + textHover: string; + focusVisibleBorder: string; + dark: string; + }; +} + +export type ThemeConfigPalette = BackstageThemePalette & { + rhdh?: RHDHThemePalette; +}; + +// Aligned with PageTheme +export interface ThemeConfigPageTheme { + backgroundColor?: string | string[]; + colors?: string | string[]; + shape?: string; + backgroundImage?: string; + fontColor?: string; +} + +export interface ThemeOptions { + components?: "rhdh" | "backstage" | "mui"; + + rippleEffect?: "on" | "off"; + + paper?: "patternfly" | "mui"; + + buttons?: "patternfly" | "mui"; + + inputs?: "patternfly" | "mui"; + + accordions?: "patternfly" | "mui"; + + sidebars?: "patternfly" | "mui"; + + pages?: "patternfly" | "mui"; + + headers?: "patternfly" | "mui"; + + toolbars?: "patternfly" | "mui"; + + dialogs?: "patternfly" | "mui"; + + cards?: "patternfly" | "mui"; + + tables?: "patternfly" | "mui"; + + tabs?: "patternfly" | "mui"; +} + +export interface ThemeConfig { + /** Optional key to load different defaults. Fallbacks to the latest `rhdh` theme if not defined. */ + variant?: "rhdh" | "rhdh-1.0" | "rhdh-1.1" | "rhdh-1.2.0" | "backstage"; + + /** Light or dark theme. Automatically selects `dark` if the theme name contains the keyword "dark". */ + mode?: "light" | "dark"; + + palette?: ThemeConfigPalette; + + fontFamily?: UnifiedThemeOptions["fontFamily"]; + + htmlFontSize?: UnifiedThemeOptions["htmlFontSize"]; + + typography?: UnifiedThemeOptions["typography"]; + + defaultPageTheme?: string; + + pageTheme?: Record; + + options?: ThemeOptions; +} + +export interface Branding { + /** + * Theme configuration. + * @deepVisibility frontend + */ + theme?: { + [key: string]: ThemeConfig; + }; +} + +export interface Config { + app: { + branding?: Branding; + }; +} diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index eaa0f22..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - createTheme as muiCreateV4Theme, - Theme as V4Theme, - ThemeOptions as V4ThemeOptions, -} from "@material-ui/core/styles"; - -import { - createTheme as muiCreateV5Theme, - Theme as V5Theme, - ThemeOptions as V5ThemeOptions, - adaptV4Theme, - DeprecatedThemeOptions, -} from "@mui/material/styles"; - -export const createV4Theme = (options: V4ThemeOptions): V4Theme => { - return muiCreateV4Theme(options); -}; - -export const createV5Theme = (options: V5ThemeOptions): V5Theme => { - return muiCreateV5Theme(options); -}; - -export const createV5ThemeFromV4Theme = ( - options: DeprecatedThemeOptions, -): V5Theme => { - return adaptV4Theme(options); -}; - -export const merge = >(...themes: T[]): T => { - return themes.reduce((acc, theme) => { - return { ...acc, ...theme }; - }, {} as T); -}; diff --git a/src/utils/createPageTheme.test.ts b/src/utils/createPageTheme.test.ts new file mode 100644 index 0000000..005a16d --- /dev/null +++ b/src/utils/createPageTheme.test.ts @@ -0,0 +1,212 @@ +import { PageTheme, shapes } from "@backstage/theme"; +import { ThemeConfigPageTheme } from "../types"; +import { createPageTheme } from "./createPageTheme"; + +interface TestCase { + name: string; + config: ThemeConfigPageTheme; + expected: PageTheme; +} + +const testCases: TestCase[] = [ + // Automatically remove the background if no data is provided + { + name: "No data", + config: {}, + expected: { + colors: ["transparent"], + shape: "none", + backgroundImage: + "none, linear-gradient(90deg, transparent, transparent)", + fontColor: "#FFFFFF", + }, + }, + + // Simple cases + { + name: "Just one color", + config: { + backgroundColor: ["#ff0000"], + }, + expected: { + colors: ["#ff0000"], + shape: "none", + backgroundImage: "none, linear-gradient(90deg, #ff0000, #ff0000)", + fontColor: "#FFFFFF", + }, + }, + { + name: "Two color gradient", + config: { + colors: ["#ff0000", "#00ff00"], + }, + expected: { + colors: ["#ff0000", "#00ff00"], + shape: "none", + backgroundImage: "none, linear-gradient(90deg, #ff0000, #00ff00)", + fontColor: "#FFFFFF", + }, + }, + { + name: "Three color gradient", + config: { + colors: ["#ff0000", "#00ff00", "#0000ff"], + }, + expected: { + colors: ["#ff0000", "#00ff00", "#0000ff"], + shape: "none", + backgroundImage: + "none, linear-gradient(90deg, #ff0000, #00ff00, #0000ff)", + fontColor: "#FFFFFF", + }, + }, + + // Shapes + { + name: "Just one color with round shape", + config: { + colors: ["#ff0000"], + shape: "round", + }, + expected: { + colors: ["#ff0000"], + shape: shapes.round, + backgroundImage: `${shapes.round}, linear-gradient(90deg, #ff0000, #ff0000)`, + fontColor: "#FFFFFF", + }, + }, + { + name: "Two color gradient with wave shape", + config: { + colors: ["#ff0000", "#00ff00"], + shape: "wave", + }, + expected: { + colors: ["#ff0000", "#00ff00"], + shape: shapes.wave, + backgroundImage: `${shapes.wave}, linear-gradient(90deg, #ff0000, #00ff00)`, + fontColor: "#FFFFFF", + }, + }, + { + name: "Three color gradient with wave2 shape", + config: { + colors: ["#ff0000", "#00ff00", "#0000ff"], + shape: "wave2", + }, + expected: { + colors: ["#ff0000", "#00ff00", "#0000ff"], + shape: shapes.wave2, + backgroundImage: `${shapes.wave2}, linear-gradient(90deg, #ff0000, #00ff00, #0000ff)`, + fontColor: "#FFFFFF", + }, + }, + + // Background images with data: + { + name: "Background image with data:...", + config: { + backgroundImage: "data:...", + }, + expected: { + colors: ["transparent"], + shape: 'url("data:...")', + backgroundImage: + 'url("data:..."), linear-gradient(90deg, transparent, transparent)', + fontColor: "#FFFFFF", + }, + }, + { + name: "Background image with url(data:...)", + config: { + backgroundImage: "url(data:...)", + }, + expected: { + colors: ["transparent"], + shape: "url(data:...)", + backgroundImage: + "url(data:...), linear-gradient(90deg, transparent, transparent)", + fontColor: "#FFFFFF", + }, + }, + + // Background images with https:// + { + name: "Background image with https://example.com/image.png", + config: { + backgroundImage: "https://example.com/image.png", + }, + expected: { + colors: ["transparent"], + shape: 'url("https://example.com/image.png")', + backgroundImage: + 'url("https://example.com/image.png"), linear-gradient(90deg, transparent, transparent)', + fontColor: "#FFFFFF", + }, + }, + { + name: "Background image with url(https://example.com/image.png)", + config: { + backgroundImage: "url(https://example.com/image.png)", + }, + expected: { + colors: ["transparent"], + shape: "url(https://example.com/image.png)", + backgroundImage: + "url(https://example.com/image.png), linear-gradient(90deg, transparent, transparent)", + fontColor: "#FFFFFF", + }, + }, + + // Background images with /static/image.png + { + name: "Background image with /static/image.png", + config: { + backgroundImage: "/static/image.png", + }, + expected: { + colors: ["transparent"], + shape: 'url("/static/image.png")', + backgroundImage: + 'url("/static/image.png"), linear-gradient(90deg, transparent, transparent)', + fontColor: "#FFFFFF", + }, + }, + { + name: "Background image with url(/static/image.png)", + config: { + backgroundImage: "url(/static/image.png)", + }, + expected: { + colors: ["transparent"], + shape: "url(/static/image.png)", + backgroundImage: + "url(/static/image.png), linear-gradient(90deg, transparent, transparent)", + fontColor: "#FFFFFF", + }, + }, + + // Background images with ... + { + name: "Background image with ...", + config: { + backgroundImage: "...", + }, + expected: { + colors: ["transparent"], + shape: 'url("data:image/svg+xml,%3Csvg%3E...%3C%2Fsvg%3E")', + backgroundImage: + 'url("data:image/svg+xml,%3Csvg%3E...%3C%2Fsvg%3E"), linear-gradient(90deg, transparent, transparent)', + fontColor: "#FFFFFF", + }, + }, +]; + +describe("createPageTheme", () => { + testCases.forEach((testCase) => { + it(testCase.name, () => { + const actual = createPageTheme(testCase.config as PageTheme); + expect(actual).toEqual(testCase.expected); + }); + }); +}); diff --git a/src/utils/createPageTheme.ts b/src/utils/createPageTheme.ts new file mode 100644 index 0000000..1d06e63 --- /dev/null +++ b/src/utils/createPageTheme.ts @@ -0,0 +1,57 @@ +import { PageTheme, genPageTheme, shapes } from "@backstage/theme"; +import { ThemeConfigPageTheme } from "../types"; + +/** + * Creates a page theme from a page theme configuration (app-config.yaml). + * + * This allows the user to specificy a custom header with: + * + * * `backgroundColor` or `colors` like `'#ff0000'`, `['#ff0000']` or `['#ff0000', '#00ff00', '#0000ff']`. + * + * More then one color will create a gradient from left to right. + * + * * a `shape` like `round`, `wave`, `wave2`, or `none`. + * + * * or an `backgroundImage` that supports different types of images references: + * + * 1. CSS urls like `data:image/svg+xml,...` or `url(data:image/svg+xml,...)` + * 2. Absolute or relative URLs starting with `http://`, `https://` or `/` + * 3. Inline ... images will be transformed to `url(data:image/svg+xml,...)` + * + * * `fontColor` like `'#ffffff'` or `'#000000'` + */ +export const createPageTheme = ( + pageThemeConfig: ThemeConfigPageTheme, +): PageTheme => { + let colors = pageThemeConfig.backgroundColor ?? pageThemeConfig.colors; + if (typeof colors === "string") { + colors = [colors]; + } else if (!colors?.length) { + colors = ["transparent"]; + } + + let shape = + pageThemeConfig.backgroundImage ?? + shapes[pageThemeConfig.shape as string] ?? + pageThemeConfig.shape ?? + "none"; + if ( + shape.startsWith("data:") || + shape.startsWith("http://") || + shape.startsWith("https://") || + shape.startsWith("/") + ) { + shape = `url("${shape}")`; + } else if (shape.startsWith("")) { + shape = `url("data:image/svg+xml,${encodeURIComponent(shape)}")`; + } + + // https://github.com/backstage/backstage/blob/master/packages/theme/src/base/pageTheme.ts#L59-L87 + return genPageTheme({ + colors, + shape, + options: { + fontColor: pageThemeConfig.fontColor, // default is white in genPageTheme! + }, + }); +}; diff --git a/src/utils/createPageThemes.test.ts b/src/utils/createPageThemes.test.ts new file mode 100644 index 0000000..681a923 --- /dev/null +++ b/src/utils/createPageThemes.test.ts @@ -0,0 +1,84 @@ +import { PageTheme, shapes } from "@backstage/theme"; +import { ThemeConfig } from "../types"; +import { createPageThemes } from "./createPageThemes"; + +interface TestCase { + name: string; + config: ThemeConfig; + expected: Record | undefined; +} + +const testCases: TestCase[] = [ + // Not specific + { + name: "No config", + config: {}, + expected: undefined, + }, + { + name: "Empty page theme", + config: { + pageTheme: {}, + }, + expected: undefined, + }, + + // One default page theme + { + name: "One page theme", + config: { + pageTheme: { + home: { + colors: ["#ff0000"], + }, + }, + }, + expected: { + home: { + colors: ["#ff0000"], + shape: "none", + backgroundImage: "none, linear-gradient(90deg, #ff0000, #ff0000)", + fontColor: "#FFFFFF", + }, + }, + }, + + // Two page themes + { + name: "Two page themes", + config: { + pageTheme: { + home: { + colors: ["#ff0000"], + }, + apis: { + colors: ["#ff0000"], + shape: "wave", + }, + }, + }, + expected: { + home: { + colors: ["#ff0000"], + shape: "none", + backgroundImage: "none, linear-gradient(90deg, #ff0000, #ff0000)", + fontColor: "#FFFFFF", + }, + apis: { + colors: ["#ff0000"], + shape: shapes.wave, + backgroundImage: `${shapes.wave}, linear-gradient(90deg, #ff0000, #ff0000)`, + fontColor: "#FFFFFF", + }, + }, + }, +]; + +describe("createPageThemes", () => { + testCases.forEach((testCase) => { + it(testCase.name, () => { + const actual = createPageThemes(testCase.config); + expect(actual).toEqual(testCase.expected); + }); + }); +}); diff --git a/src/utils/createPageThemes.ts b/src/utils/createPageThemes.ts new file mode 100644 index 0000000..cb8ea2c --- /dev/null +++ b/src/utils/createPageThemes.ts @@ -0,0 +1,19 @@ +import { PageTheme } from "@backstage/theme"; +import { ThemeConfig } from "../types"; +import { createPageTheme } from "./createPageTheme"; + +export const createPageThemes = ( + themeConfig: ThemeConfig | undefined, +): Record | undefined => { + if (!themeConfig?.pageTheme || !Object.keys(themeConfig.pageTheme).length) { + return undefined; + } + + const pageThemes: Record = {}; + + for (const page in themeConfig.pageTheme) { + pageThemes[page] = createPageTheme(themeConfig.pageTheme[page]); + } + + return pageThemes; +}; diff --git a/src/utils/mergeTheme.test.ts b/src/utils/mergeTheme.test.ts new file mode 100644 index 0000000..23673c6 --- /dev/null +++ b/src/utils/mergeTheme.test.ts @@ -0,0 +1,199 @@ +import { UnifiedThemeOptions } from "@backstage/theme"; +import { mergeUnifiedThemeOptions } from "./mergeTheme"; + +type TestValue = string | string[] | null; + +interface TestValues { + value?: TestValue; + anotherValue?: TestValue; + deeper?: { + value?: TestValue; + anotherValue?: TestValue; + } | null; +} + +interface TestCase { + name: string; + // Allow us to test that the merge works in a generic way + default: TestValues; + customized: TestValues; + expected: TestValues; +} + +const testCases: TestCase[] = [ + // Simple cases + { + name: "No data", + default: {}, + customized: {}, + expected: {}, + }, + { + name: "Keep default", + default: { value: "default value" }, + customized: {}, + expected: { value: "default value" }, + }, + { + name: "Use customization", + default: {}, + customized: { value: "custom value" }, + expected: { value: "custom value" }, + }, + { + name: "Override default with customization", + default: { value: "default value" }, + customized: { value: "custom value" }, + expected: { value: "custom value" }, + }, + // Mixing + { + name: "Mixing default and customization", + default: { value: "default value" }, + customized: { anotherValue: "custom value" }, + expected: { value: "default value", anotherValue: "custom value" }, + }, + // Go deeper + { + name: "Deeper without data", + default: { deeper: {} }, + customized: { deeper: {} }, + expected: { deeper: {} }, + }, + { + name: "Deeper keep default", + default: { + value: "default value", + deeper: { value: "deeper default value" }, + }, + customized: {}, + expected: { + value: "default value", + deeper: { value: "deeper default value" }, + }, + }, + { + name: "Deeper use customization", + default: {}, + customized: { + value: "custom value", + deeper: { value: "deeper custom value" }, + }, + expected: { + value: "custom value", + deeper: { value: "deeper custom value" }, + }, + }, + { + name: "Deeper override default with customization", + default: { + value: "default value", + deeper: { value: "deeper default value" }, + }, + customized: { + value: "custom value", + deeper: { value: "deeper custom value" }, + }, + expected: { + value: "custom value", + deeper: { value: "deeper custom value" }, + }, + }, + { + name: "Deeper mixing default and customization", + default: { + value: "default value", + deeper: { + value: "deeper default value", + }, + }, + customized: { + anotherValue: "custom value", + deeper: { + anotherValue: "deeper custom value", + }, + }, + expected: { + value: "default value", + anotherValue: "custom value", + deeper: { + value: "deeper default value", + anotherValue: "deeper custom value", + }, + }, + }, + // Null and undefined + { + name: "Null values overrides the default", + default: { value: "default value" }, + customized: { value: null }, + expected: { value: null }, + }, + { + name: "Null value overrides an object as well", + default: { deeper: { value: "default value" } }, + customized: { deeper: null }, + expected: { deeper: null }, + }, + { + name: "Missing values keep the default", + default: { value: "default value" }, + customized: {}, + expected: { value: "default value" }, + }, + { + name: "Undefined values keep the default", + default: { value: "default value" }, + customized: { value: undefined }, + expected: { value: "default value" }, + }, + // Arrays + { + name: "Default arrays are kept as well", + default: { value: ["a"] }, + customized: {}, + expected: { value: ["a"] }, + }, + { + name: "Deeper default arrays are kept as well", + default: { deeper: { value: ["a"] } }, + customized: {}, + expected: { deeper: { value: ["a"] } }, + }, + { + name: "Custom arrays are used as well", + default: {}, + customized: { value: ["a"] }, + expected: { value: ["a"] }, + }, + { + name: "Deeper default arrays are kept as well", + default: {}, + customized: { deeper: { value: ["a"] } }, + expected: { deeper: { value: ["a"] } }, + }, + { + name: "Arrays are overridden, not merged", + default: { value: ["a", "b"] }, + customized: { value: ["c", "d"] }, + expected: { value: ["c", "d"] }, + }, + { + name: "Deeper arrays are also overridden, not merged", + default: { deeper: { value: ["a", "b"] } }, + customized: { deeper: { value: ["c", "d"] } }, + expected: { deeper: { value: ["c", "d"] } }, + }, +]; + +describe("mergeThemeConfig", () => { + testCases.forEach((testCase) => { + it(testCase.name, () => { + const actual = mergeUnifiedThemeOptions( + testCase.default as UnifiedThemeOptions, + testCase.customized as UnifiedThemeOptions, + ); + expect(actual).toEqual(testCase.expected); + }); + }); +}); diff --git a/src/utils/mergeTheme.ts b/src/utils/mergeTheme.ts new file mode 100644 index 0000000..0b7e303 --- /dev/null +++ b/src/utils/mergeTheme.ts @@ -0,0 +1,77 @@ +import { ThemeConfig } from "../types"; + +function isObject( + objectOrValue: unknown, +): objectOrValue is Record { + return ( + objectOrValue !== undefined && + objectOrValue !== null && + typeof objectOrValue === "object" && + !Array.isArray(objectOrValue) + ); +} + +const deepCopyObject = (objectOrValue: unknown): unknown => { + if (isObject(objectOrValue)) { + const result: Record = {}; + for (const key of Object.keys(objectOrValue)) { + result[key] = deepCopyObject(objectOrValue[key]); + } + return result; + } + return objectOrValue; +}; + +export const deepMergeObjects = ( + defaultValue: unknown, + customValue: unknown, +): unknown => { + if (isObject(defaultValue) || isObject(customValue)) { + const result: Record = {}; + if (isObject(defaultValue) && isObject(customValue)) { + for (const key of Object.keys(defaultValue)) { + result[key] = deepMergeObjects(defaultValue[key], customValue[key]); + } + for (const key of Object.keys(customValue)) { + result[key] = deepMergeObjects(defaultValue[key], customValue[key]); + } + } else if (customValue === null) { + return null; + } else if (isObject(customValue)) { + for (const key of Object.keys(customValue)) { + result[key] = deepCopyObject(customValue[key]); + } + } else if (isObject(defaultValue)) { + for (const key of Object.keys(defaultValue)) { + result[key] = deepCopyObject(defaultValue[key]); + } + } + return result; + } + return customValue !== undefined ? customValue : defaultValue; +}; + +/** + * Merges two theme configurations with this priority: + * + * 1. Values will be picked up first from the `defaultThemeConfig` and + * then from the `customizedThemeConfig`. + * This means that if a value is set in the customized theme, it will 'override' the default. + * 2. Null values in `customizedThemeConfig` the will override the default with `null`, + * while undefined values will be ignored. + * 3. Objects will be merged recursively. + * All other values, esp. Arrays, will not be merged. + * + * @param defaultThemeConfig + * @param customizedThemeConfig + * @returns + */ +export const mergeUnifiedThemeOptions = ( + defaultThemeConfig: ThemeConfig, + customizedThemeConfig: ThemeConfig, +): ThemeConfig => { + return deepMergeObjects( + defaultThemeConfig, + customizedThemeConfig, + ) as ThemeConfig; +}; diff --git a/src/utils/migrateTheme.test.ts b/src/utils/migrateTheme.test.ts new file mode 100644 index 0000000..88e88fc --- /dev/null +++ b/src/utils/migrateTheme.test.ts @@ -0,0 +1,192 @@ +import { BackstagePaletteAdditions } from "@backstage/theme"; +import { ThemeConfig } from "../types"; +import { + migrateThemeConfig, + DeprecatedRHDH10to12ThemeColors, +} from "./migrateTheme"; + +interface TestCase { + name: string; + config: ThemeConfig & Partial; + expected: ThemeConfig; +} + +const testCases: TestCase[] = [ + { + name: "No data", + config: {}, + expected: { + palette: {}, + }, + }, + + // Migrate primaryColor + { + name: "Use old primary color if defined", + config: { + primaryColor: "#ff0000", + }, + expected: { + palette: { + primary: { + main: "#ff0000", + }, + }, + }, + }, + { + name: "Prefer palette primary color if defined", + config: { + primaryColor: "#ff0000", + palette: { + primary: { + main: "#00ff00", + }, + }, + }, + expected: { + palette: { + primary: { + main: "#00ff00", + }, + }, + }, + }, + { + name: "Merge palette primary color if defined", + config: { + primaryColor: "#ff0000", + palette: { + primary: { + main: "", + dark: "#0000ff", + }, + }, + }, + expected: { + palette: { + primary: { + main: "#ff0000", + dark: "#0000ff", + }, + }, + }, + }, + + // Migrate headerColor1 and headerColor2 + { + name: "Use old headerColor1 if defined", + config: { + headerColor1: "#ff0000", + }, + expected: { + palette: {}, + defaultPageTheme: "home", + pageTheme: { + home: { + backgroundColor: "#ff0000", + }, + }, + }, + }, + { + name: "Use old headerColor1 and headerColor2 if defined", + config: { + headerColor1: "#ff0000", + headerColor2: "#00ff00", + }, + expected: { + palette: {}, + defaultPageTheme: "home", + pageTheme: { + home: { + backgroundColor: ["#ff0000", "#00ff00"], + }, + }, + }, + }, + { + name: "Ignore headerColor1 or headerColor2 if pageTheme is defined", + config: { + headerColor1: "#ff0000", + headerColor2: "#00ff00", + pageTheme: { + home: { + backgroundColor: "#0000ff", + }, + }, + }, + expected: { + palette: {}, + pageTheme: { + home: { + backgroundColor: "#0000ff", + }, + }, + }, + }, + + // Migrate navigationIndicatorColor + { + name: "Use old navigation indicator color if defined", + config: { + navigationIndicatorColor: "#ff0000", + }, + expected: { + palette: { + navigation: { + // background: '', + indicator: "#ff0000", + // color: '', + // selectedColor: "", + } as BackstagePaletteAdditions["navigation"], + }, + }, + }, + // { + // name: "Prefer palette primary color if defined", + // config: { + // primaryColor: "#ff0000", + // palette: { + // primary: { + // main: "#00ff00", + // }, + // }, + // }, + // expected: { + // palette: { + // primary: { + // main: "#00ff00", + // }, + // }, + // }, + // }, + // { + // name: "Merge palette primary color if defined", + // config: { + // primaryColor: "#ff0000", + // palette: { + // primary: { + // dark: "#0000ff", + // }, + // }, + // }, + // expected: { + // palette: { + // primary: { + // main: "#ff0000", + // dark: "#0000ff", + // }, + // }, + // }, + // }, +]; + +describe("migrateThemeConfig", () => { + testCases.forEach((testCase) => { + it(testCase.name, () => { + const actual = migrateThemeConfig(testCase.config); + expect(actual).toEqual(testCase.expected); + }); + }); +}); diff --git a/src/utils/migrateTheme.ts b/src/utils/migrateTheme.ts new file mode 100644 index 0000000..078eaea --- /dev/null +++ b/src/utils/migrateTheme.ts @@ -0,0 +1,102 @@ +import type { BackstagePaletteAdditions } from "@backstage/theme"; +import type { SimplePaletteColorOptions } from "@mui/material"; +import type { ThemeConfig } from "../types"; + +export interface DeprecatedRHDH10to12ThemeColors { + /** + * primaryColor Configuration for the instance + * The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + */ + primaryColor?: string; + /** + * Header Theme color Configuration for the instance + * The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + */ + headerColor1?: string; + /** + * Header Theme color Configuration for the instance + * The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + */ + headerColor2?: string; + /** + * Navigation Side Bar Indicator color Configuration for the instance + * The following formats are supported: #nnn, #nnnnnn, rgb(), rgba(), hsl(), hsla(), color() + */ + navigationIndicatorColor?: string; +} + +function isObject( + objectOrValue: unknown, +): objectOrValue is Record { + return typeof objectOrValue === "object" && !Array.isArray(objectOrValue); +} + +export const migrateThemeConfig = ( + themeConfig: ThemeConfig & DeprecatedRHDH10to12ThemeColors, +): ThemeConfig => { + if (!themeConfig) { + return { palette: {} }; + } + + // Drop deprecated values from the root level! + const migrated: ThemeConfig = { + palette: themeConfig.palette ?? {}, + defaultPageTheme: themeConfig.defaultPageTheme, + pageTheme: themeConfig.pageTheme, + fontFamily: themeConfig.fontFamily, + htmlFontSize: themeConfig.htmlFontSize, + typography: themeConfig.typography, + }; + + if (themeConfig.primaryColor) { + console.warn( + "[deprecated] Automatically migrate theme configuration from `primaryColor` to `palette.primary.main`", + ); + if (isObject(migrated.palette?.primary)) { + if (!(migrated.palette.primary as SimplePaletteColorOptions).main) { + (migrated.palette.primary as SimplePaletteColorOptions).main = + themeConfig.primaryColor; + } + } else if (migrated.palette) { + migrated.palette.primary = { main: themeConfig.primaryColor }; + } + } + + if (themeConfig.navigationIndicatorColor) { + console.warn( + "[deprecated] Automatically migrate theme configuration from `navigationIndicatorColor` to `palette.navigation.indicator`", + ); + if (isObject(migrated.palette?.navigation)) { + if (!migrated.palette.navigation.indicator) { + migrated.palette.navigation.indicator = + themeConfig.navigationIndicatorColor; + } + } else if (migrated.palette) { + migrated.palette.navigation = { + indicator: themeConfig.navigationIndicatorColor, + } as BackstagePaletteAdditions["navigation"]; + } + } + + if (themeConfig.headerColor1) { + if (themeConfig.pageTheme) { + console.warn( + "[deprecated] Ignore theme configuration `headerColor1` and `headerColor2` because `pageTheme` is defined!", + ); + } else { + console.warn( + "[deprecated] Automatically migrate theme configuration from `headerColor1` and `headerColor2` to `pageTheme.home.backgroundColor`", + ); + migrated.defaultPageTheme = "home"; + migrated.pageTheme = { + home: { + backgroundColor: !themeConfig.headerColor2 + ? themeConfig.headerColor1 + : [themeConfig.headerColor1, themeConfig.headerColor2], + }, + }; + } + } + + return migrated; +};