From cfe56ae7ef77d3bd0eeeda14c6f15c6bf50f9cdb Mon Sep 17 00:00:00 2001 From: Alexander Forselius Date: Tue, 31 Mar 2020 17:57:42 +0200 Subject: [PATCH 1/3] Implemented dark mode --- packages/app/src/App.test.tsx | 11 + packages/app/src/App.tsx | 50 +++- packages/app/src/ThemeContext.tsx | 60 ++++ packages/app/src/components/Root/Root.tsx | 2 + .../Root/ToggleThemeSidebarItem.tsx | 56 ++++ packages/core/src/index.ts | 2 + packages/core/src/theme/BackstageThemeDark.js | 265 ++++++++++++++++++ .../core/src/theme/BackstageThemeLight.js | 263 +++++++++++++++++ 8 files changed, 699 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/ThemeContext.tsx create mode 100644 packages/app/src/components/Root/ToggleThemeSidebarItem.tsx create mode 100644 packages/core/src/theme/BackstageThemeDark.js create mode 100644 packages/core/src/theme/BackstageThemeLight.js diff --git a/packages/app/src/App.test.tsx b/packages/app/src/App.test.tsx index ace8f42f45..83fcc039a3 100644 --- a/packages/app/src/App.test.tsx +++ b/packages/app/src/App.test.tsx @@ -19,6 +19,17 @@ import { render } from '@testing-library/react'; import App from './App'; describe('App', () => { + beforeAll(() => { + Object.defineProperty(window, 'matchMedia', { + value: jest.fn(() => { + return { + matches: true, + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + }; + }), + }); + }); it('should render', () => { const rendered = render(); expect(rendered.baseElement).toBeInTheDocument(); diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 09d7f55094..ce5ace24d1 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -15,13 +15,18 @@ */ import { CssBaseline, makeStyles, ThemeProvider } from '@material-ui/core'; -import { BackstageTheme, createApp } from '@backstage/core'; +import { + BackstageThemeLight, + BackstageThemeDark, + createApp, +} from '@backstage/core'; import React, { FC } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import Root from './components/Root'; import ErrorDisplay from './components/ErrorDisplay'; import * as plugins from './plugins'; import apis, { errorDialogForwarder } from './apis'; +import { ThemeContextType, ThemeContext, useThemeType } from './ThemeContext'; const useStyles = makeStyles(theme => ({ '@global': { @@ -48,17 +53,42 @@ const AppComponent = app.build(); const App: FC<{}> = () => { useStyles(); + const [theme, toggleTheme] = useThemeType( + localStorage.getItem('theme') || 'auto', + ); + + let backstageTheme = BackstageThemeLight; + switch (theme) { + case 'light': + backstageTheme = BackstageThemeLight; + break; + case 'dark': + backstageTheme = BackstageThemeDark; + break; + default: + backstageTheme = window.matchMedia('(prefers-color-scheme: dark)').matches + ? BackstageThemeDark + : BackstageThemeLight; + break; + } + + const themeContext: ThemeContextType = { + theme, + toggleTheme, + }; return ( - - - - - - - - + + + + + + + + + + - + ); }; diff --git a/packages/app/src/ThemeContext.tsx b/packages/app/src/ThemeContext.tsx new file mode 100644 index 0000000000..81988db44f --- /dev/null +++ b/packages/app/src/ThemeContext.tsx @@ -0,0 +1,60 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useState, useEffect } from 'react'; +export type ThemeContextType = { + theme: string; + toggleTheme: () => void; +}; +export const ThemeContext = React.createContext({ + theme: 'light', + toggleTheme: () => {}, +}); + +export function useThemeType(themeId: string): [string, () => void] { + const [theme, setTheme] = useState(themeId); + useEffect(() => { + const mql = window.matchMedia('(prefers-color-scheme: dark)'); + const darkListener = (event: MediaQueryListEvent) => { + if (localStorage.getItem('theme') === 'auto') { + if (event.matches) { + setTheme('dark'); + } else { + setTheme('light'); + } + setTheme('auto'); + } + }; + mql.addEventListener('change', darkListener); + return () => { + mql.removeEventListener('change', darkListener); + }; + }); + function toggleTheme() { + if (theme === 'light') { + setTheme('dark'); + localStorage.setItem('theme', 'dark'); + } else if (theme === 'dark') { + setTheme('auto'); + localStorage.setItem('theme', 'auto'); + setTheme('auto'); + } else { + setTheme('light'); + localStorage.setItem('theme', 'light'); + } + } + return [theme, toggleTheme]; +} diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 920f5c9871..d4fead2dd9 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -29,6 +29,7 @@ import { SidebarDivider, SidebarSpace, } from '@backstage/core'; +import ToggleThemeSidebarItem from './ToggleThemeSidebarItem'; const useSidebarLogoStyles = makeStyles({ root: { @@ -81,6 +82,7 @@ const Root: FC<{}> = ({ children }) => ( + {children} diff --git a/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx b/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx new file mode 100644 index 0000000000..6c0cd09244 --- /dev/null +++ b/packages/app/src/components/Root/ToggleThemeSidebarItem.tsx @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { SidebarItem } from '@backstage/core'; +import { ThemeContext } from '../../ThemeContext'; +import WbSunnyIcon from '@material-ui/icons/WbSunny'; +import Brightness2Icon from '@material-ui/icons/Brightness2'; +import ToggleOnIcon from '@material-ui/icons/ToggleOn'; +const ToggleThemeSidebarItem: FC<{}> = () => { + return ( + + {themeContext => { + let text = 'Auto'; + let icon = ToggleOnIcon; + switch (themeContext.theme) { + case 'dark': + text = 'Dark mode'; + icon = Brightness2Icon; + break; + case 'light': + text = 'Light mode'; + icon = WbSunnyIcon; + break; + default: + text = 'Auto'; + icon = ToggleOnIcon; + break; + } + + return ( + + ); + }} + + ); +}; + +export default ToggleThemeSidebarItem; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5dd7564bca..a9036084cd 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -24,8 +24,10 @@ export { default as HeaderLabel } from './layout/HeaderLabel'; export { default as InfoCard } from './layout/InfoCard'; export { default as ErrorBoundary } from './layout/ErrorBoundary'; export * from './layout/Sidebar'; +export { default as BackstageThemeLight } from './theme/BackstageThemeLight'; export { default as BackstageTheme } from './theme/BackstageTheme'; export { COLORS } from './theme/BackstageTheme'; +export { default as BackstageThemeDark } from './theme/BackstageThemeDark'; export { default as HorizontalScrollGrid } from './components/HorizontalScrollGrid'; export { default as ProgressCard } from './components/ProgressCard'; export { default as CircleProgress } from './components/CircleProgress'; diff --git a/packages/core/src/theme/BackstageThemeDark.js b/packages/core/src/theme/BackstageThemeDark.js new file mode 100644 index 0000000000..b5562a50ba --- /dev/null +++ b/packages/core/src/theme/BackstageThemeDark.js @@ -0,0 +1,265 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createMuiTheme } from '@material-ui/core'; +import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; +import { blue, yellow } from '@material-ui/core/colors'; + +export const COLORS = { + PAGE_BACKGROUND: '#282828', + EFAULT_PAGE_THEME_COLOR: '#232323', + DEFAULT_PAGE_THEME_COLOR: '#7C3699', + DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', + ERROR_BACKGROUND_COLOR: '#FFEBEE', + ERROR_TEXT_COLOR: '#CA001B', + INFO_TEXT_COLOR: '#004e8a', + LINK_TEXT: '#0A6EBE', + LINK_TEXT_HOVER: '#2196F3', + NAMED: { + WHITE: '#FEFEFE', + }, + STATUS: { + OK: '#1db855', + WARNING: '#f49b20', + ERROR: '#CA001B', + }, +}; + +const extendedThemeConfig = { + props: { + MuiGrid: { + spacing: 2, + }, + MuiSwitch: { + color: 'primary', + }, + }, + palette: { + background: { + default: COLORS.PAGE_BACKGROUND, + informational: '#60a3cb', + }, + color: { + default: '#fff', + }, + type: 'dark', + status: { + ok: COLORS.STATUS.OK, + warning: COLORS.STATUS.WARNING, + error: COLORS.STATUS.ERROR, + running: '#BEBEBE', + pending: '#5BC0DE', + background: COLORS.NAMED.WHITE, + }, + bursts: { + fontColor: COLORS.NAMED.WHITE, + slackChannelText: '#ddd', + backgroundColor: { + default: COLORS.DEFAULT_PAGE_THEME_COLOR, + }, + }, + primary: { + main: blue[500], + }, + border: '#E6E6E6', + textVerySubtle: '#DDD', + textSubtle: '#6E6E6E', + highlight: '#FFFBCC', + errorBackground: COLORS.ERROR_BACKGROUND_COLOR, + warningBackground: '#F59B23', + infoBackground: '#ebf5ff', + errorText: COLORS.ERROR_TEXT_COLOR, + infoText: COLORS.INFO_TEXT_COLOR, + warningText: COLORS.NAMED.WHITE, + linkHover: COLORS.LINK_TEXT_HOVER, + link: COLORS.LINK_TEXT, + gold: yellow.A700, + }, + navigation: { + width: 220, + background: '#333333', + }, + typography: { + fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', + h5: { + fontWeight: 700, + }, + h4: { + fontWeight: 700, + fontSize: 28, + marginBottom: 6, + }, + h3: { + fontSize: 32, + fontWeight: 700, + marginBottom: 6, + }, + h2: { + fontSize: 40, + fontWeight: 700, + marginBottom: 8, + }, + h1: { + fontSize: 54, + fontWeight: 700, + marginBottom: 10, + }, + }, +}; + +const createOverrides = theme => { + return { + overrides: { + MuiCSSBaseline: { + '@global': { + body: { + backgroundColor: theme.palette.background.default, + color: theme.palette.color.default, + }, + }, + }, + MuiTableRow: { + // Alternating row backgrounds + root: { + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.background.default, + }, + }, + // Use pointer for hoverable rows + hover: { + '&:hover': { + cursor: 'pointer', + }, + }, + // Alternating head backgrounds + head: { + '&:nth-of-type(odd)': { + backgroundColor: COLORS.NAMED.WHITE, + }, + }, + }, + // Tables are more dense than default mui tables + MuiTableCell: { + root: { + wordBreak: 'break-word', + overflow: 'hidden', + verticalAlign: 'middle', + lineHeight: '1', + margin: 0, + padding: '8px', + borderBottom: 0, + }, + head: { + wordBreak: 'break-word', + overflow: 'hidden', + color: 'rgb(179, 179, 179)', + fontWeight: 'normal', + lineHeight: '1', + }, + }, + MuiTabs: { + // Tabs are smaller than default mui tab rows + root: { + minHeight: 24, + }, + }, + MuiTab: { + // Tabs are smaller and have a hover background + root: { + color: theme.palette.link, + minHeight: 24, + textTransform: 'initial', + '&:hover': { + color: darken(theme.palette.link, 0.3), + background: lighten(theme.palette.link, 0.95), + }, + [theme.breakpoints.up('md')]: { + minWidth: 120, + fontSize: theme.typography.pxToRem(14), + fontWeight: 500, + }, + }, + textColorPrimary: { + color: theme.palette.link, + }, + }, + MuiTableSortLabel: { + // No color change on hover, just rely on the arrow showing up instead. + root: { + color: 'inherit', + '&:hover': { + color: 'inherit', + }, + '&:focus': { + color: 'inherit', + }, + }, + // Bold font for highlighting selected column + active: { + fontWeight: 'bold', + color: 'inherit', + }, + }, + MuiListItemText: { + dense: { + // Default dense list items to adding ellipsis for really long str... + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, + MuiButton: { + text: { + // Text buttons have less padding by default, but we want to keep the original padding + padding: null, + }, + }, + MuiChip: { + root: { + // By default there's no margin, but it's usually wanted, so we add some trailing margin + marginRight: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + }, + MuiCardHeader: { + root: { + // Reduce padding between header and content + paddingBottom: 0, + }, + }, + MuiCardActions: { + root: { + // We default to putting the card actions at the end + justifyContent: 'flex-end', + }, + }, + }, + }; +}; + +const extendedTheme = createMuiTheme(extendedThemeConfig); + +// V1 theming +// https://material-ui-next.com/customization/themes/ +// For CSS it is advised to use JSS, see https://material-ui-next.com/customization/css-in-js/ +const BackstageThemeDark = { + ...extendedTheme, + ...createOverrides(extendedTheme), +}; + +// Temporary workaround for files incorrectly importing the theme directly +export const V1 = BackstageThemeDark; +export default BackstageThemeDark; diff --git a/packages/core/src/theme/BackstageThemeLight.js b/packages/core/src/theme/BackstageThemeLight.js new file mode 100644 index 0000000000..4f44a0c2b2 --- /dev/null +++ b/packages/core/src/theme/BackstageThemeLight.js @@ -0,0 +1,263 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createMuiTheme } from '@material-ui/core'; +import { darken, lighten } from '@material-ui/core/styles/colorManipulator'; +import { blue, yellow } from '@material-ui/core/colors'; + +export const COLORS = { + PAGE_BACKGROUND: '#F8F8F8', + DEFAULT_PAGE_THEME_COLOR: '#7C3699', + DEFAULT_PAGE_THEME_LIGHT_COLOR: '#ECDBF2', + ERROR_BACKGROUND_COLOR: '#FFEBEE', + ERROR_TEXT_COLOR: '#CA001B', + INFO_TEXT_COLOR: '#004e8a', + LINK_TEXT: '#0A6EBE', + LINK_TEXT_HOVER: '#2196F3', + NAMED: { + WHITE: '#FEFEFE', + }, + STATUS: { + OK: '#1db855', + WARNING: '#f49b20', + ERROR: '#CA001B', + }, +}; + +const extendedThemeConfig = { + props: { + MuiGrid: { + spacing: 2, + }, + MuiSwitch: { + color: 'primary', + }, + }, + palette: { + background: { + default: COLORS.PAGE_BACKGROUND, + informational: '#60a3cb', + }, + color: { + default: '#000', + }, + status: { + ok: COLORS.STATUS.OK, + warning: COLORS.STATUS.WARNING, + error: COLORS.STATUS.ERROR, + running: '#BEBEBE', + pending: '#5BC0DE', + background: COLORS.NAMED.WHITE, + }, + bursts: { + fontColor: COLORS.NAMED.WHITE, + slackChannelText: '#ddd', + backgroundColor: { + default: COLORS.DEFAULT_PAGE_THEME_COLOR, + }, + }, + primary: { + main: blue[500], + }, + border: '#E6E6E6', + textVerySubtle: '#DDD', + textSubtle: '#6E6E6E', + highlight: '#FFFBCC', + errorBackground: COLORS.ERROR_BACKGROUND_COLOR, + warningBackground: '#F59B23', + infoBackground: '#ebf5ff', + errorText: COLORS.ERROR_TEXT_COLOR, + infoText: COLORS.INFO_TEXT_COLOR, + warningText: COLORS.NAMED.WHITE, + linkHover: COLORS.LINK_TEXT_HOVER, + link: COLORS.LINK_TEXT, + gold: yellow.A700, + }, + navigation: { + width: 220, + background: '#333333', + }, + typography: { + fontFamily: '"Helvetica Neue", Helvetica, Roboto, Arial, sans-serif', + h5: { + fontWeight: 700, + }, + h4: { + fontWeight: 700, + fontSize: 28, + marginBottom: 6, + }, + h3: { + fontSize: 32, + fontWeight: 700, + marginBottom: 6, + }, + h2: { + fontSize: 40, + fontWeight: 700, + marginBottom: 8, + }, + h1: { + fontSize: 54, + fontWeight: 700, + marginBottom: 10, + }, + }, +}; + +const createOverrides = theme => { + return { + overrides: { + MuiCSSBaseline: { + '@global': { + body: { + backgroundColor: theme.palette.background.default, + color: theme.palette.color.default, + }, + }, + }, + MuiTableRow: { + // Alternating row backgrounds + root: { + '&:nth-of-type(odd)': { + backgroundColor: theme.palette.background.default, + }, + }, + // Use pointer for hoverable rows + hover: { + '&:hover': { + cursor: 'pointer', + }, + }, + // Alternating head backgrounds + head: { + '&:nth-of-type(odd)': { + backgroundColor: COLORS.NAMED.WHITE, + }, + }, + }, + // Tables are more dense than default mui tables + MuiTableCell: { + root: { + wordBreak: 'break-word', + overflow: 'hidden', + verticalAlign: 'middle', + lineHeight: '1', + margin: 0, + padding: '8px', + borderBottom: 0, + }, + head: { + wordBreak: 'break-word', + overflow: 'hidden', + color: 'rgb(179, 179, 179)', + fontWeight: 'normal', + lineHeight: '1', + }, + }, + MuiTabs: { + // Tabs are smaller than default mui tab rows + root: { + minHeight: 24, + }, + }, + MuiTab: { + // Tabs are smaller and have a hover background + root: { + color: theme.palette.link, + minHeight: 24, + textTransform: 'initial', + '&:hover': { + color: darken(theme.palette.link, 0.3), + background: lighten(theme.palette.link, 0.95), + }, + [theme.breakpoints.up('md')]: { + minWidth: 120, + fontSize: theme.typography.pxToRem(14), + fontWeight: 500, + }, + }, + textColorPrimary: { + color: theme.palette.link, + }, + }, + MuiTableSortLabel: { + // No color change on hover, just rely on the arrow showing up instead. + root: { + color: 'inherit', + '&:hover': { + color: 'inherit', + }, + '&:focus': { + color: 'inherit', + }, + }, + // Bold font for highlighting selected column + active: { + fontWeight: 'bold', + color: 'inherit', + }, + }, + MuiListItemText: { + dense: { + // Default dense list items to adding ellipsis for really long str... + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + }, + MuiButton: { + text: { + // Text buttons have less padding by default, but we want to keep the original padding + padding: null, + }, + }, + MuiChip: { + root: { + // By default there's no margin, but it's usually wanted, so we add some trailing margin + marginRight: theme.spacing(1), + marginBottom: theme.spacing(1), + }, + }, + MuiCardHeader: { + root: { + // Reduce padding between header and content + paddingBottom: 0, + }, + }, + MuiCardActions: { + root: { + // We default to putting the card actions at the end + justifyContent: 'flex-end', + }, + }, + }, + }; +}; + +const extendedTheme = createMuiTheme(extendedThemeConfig); + +// V1 theming +// https://material-ui-next.com/customization/themes/ +// For CSS it is advised to use JSS, see https://material-ui-next.com/customization/css-in-js/ +const BackstageThemeLight = { + ...extendedTheme, + ...createOverrides(extendedTheme), +}; + +// Temporary workaround for files incorrectly importing the theme directly +export const V1 = BackstageThemeLight; +export default BackstageThemeLight; From d37fcc09706d78b4e0fb2439d25fb86b5e9a92b6 Mon Sep 17 00:00:00 2001 From: Alexander Forselius Date: Wed, 1 Apr 2020 19:41:12 +0200 Subject: [PATCH 2/3] Added feature check for window.matchMedia --- packages/app/src/ThemeContext.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/app/src/ThemeContext.tsx b/packages/app/src/ThemeContext.tsx index 81988db44f..4ee76d400e 100644 --- a/packages/app/src/ThemeContext.tsx +++ b/packages/app/src/ThemeContext.tsx @@ -27,6 +27,9 @@ export const ThemeContext = React.createContext({ export function useThemeType(themeId: string): [string, () => void] { const [theme, setTheme] = useState(themeId); useEffect(() => { + if (!window.matchMedia) { + return () => {}; + } const mql = window.matchMedia('(prefers-color-scheme: dark)'); const darkListener = (event: MediaQueryListEvent) => { if (localStorage.getItem('theme') === 'auto') { @@ -48,6 +51,12 @@ export function useThemeType(themeId: string): [string, () => void] { setTheme('dark'); localStorage.setItem('theme', 'dark'); } else if (theme === 'dark') { + if (!window.matchMedia) { + setTheme('light'); + localStorage.setItem('theme', 'light'); + setTheme('light'); + return; + } setTheme('auto'); localStorage.setItem('theme', 'auto'); setTheme('auto'); From c80074cfdfed923d23a825a3e77f725e467379dc Mon Sep 17 00:00:00 2001 From: Alexander Forselius Date: Thu, 2 Apr 2020 07:14:41 +0200 Subject: [PATCH 3/3] Added window.matchMedia to App.tsx --- packages/app/src/App.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ce5ace24d1..24dee65278 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -66,6 +66,10 @@ const App: FC<{}> = () => { backstageTheme = BackstageThemeDark; break; default: + if (!window.matchMedia) { + backstageTheme = BackstageThemeLight; + break; + } backstageTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? BackstageThemeDark : BackstageThemeLight;