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..24dee65278 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,46 @@ 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:
+ if (!window.matchMedia) {
+ backstageTheme = BackstageThemeLight;
+ break;
+ }
+ 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..4ee76d400e
--- /dev/null
+++ b/packages/app/src/ThemeContext.tsx
@@ -0,0 +1,69 @@
+/*
+ * 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(() => {
+ if (!window.matchMedia) {
+ return () => {};
+ }
+ 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') {
+ if (!window.matchMedia) {
+ setTheme('light');
+ localStorage.setItem('theme', 'light');
+ setTheme('light');
+ return;
+ }
+ 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 ad9dde477f..33054ae81c 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;