diff --git a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts index 3b996e68e50b3..bf77a2531660a 100644 --- a/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts +++ b/packages/core/injected-metadata/core-injected-metadata-browser-internal/src/types.ts @@ -6,12 +6,12 @@ * Side Public License, v 1. */ -import { ThemeVersion } from '@kbn/ui-shared-deps-npm'; import { InjectedMetadata, InjectedMetadataClusterInfo, InjectedMetadataExternalUrlPolicy, InjectedMetadataPlugin, + InjectedMetadataTheme, } from '@kbn/core-injected-metadata-common-internal'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; @@ -39,10 +39,7 @@ export interface InternalInjectedMetadataSetup { getExternalUrlConfig: () => { policy: InjectedMetadataExternalUrlPolicy[]; }; - getTheme: () => { - darkMode: boolean; - version: ThemeVersion; - }; + getTheme: () => InjectedMetadataTheme; getElasticsearchInfo: () => InjectedMetadataClusterInfo; /** * An array of frontend plugins in topological order. diff --git a/packages/core/injected-metadata/core-injected-metadata-common-internal/index.ts b/packages/core/injected-metadata/core-injected-metadata-common-internal/index.ts index e6cb215e87499..ce66a5189ac9a 100644 --- a/packages/core/injected-metadata/core-injected-metadata-common-internal/index.ts +++ b/packages/core/injected-metadata/core-injected-metadata-common-internal/index.ts @@ -10,5 +10,6 @@ export type { InjectedMetadata, InjectedMetadataClusterInfo, InjectedMetadataExternalUrlPolicy, + InjectedMetadataTheme, InjectedMetadataPlugin, } from './src/types'; diff --git a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts index 01b46679c7452..a7d4f34cfcae5 100644 --- a/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts +++ b/packages/core/injected-metadata/core-injected-metadata-common-internal/src/types.ts @@ -10,6 +10,7 @@ import type { PluginName, DiscoveredPlugin } from '@kbn/core-base-common'; import type { ThemeVersion } from '@kbn/ui-shared-deps-npm'; import type { EnvironmentMode, PackageInfo } from '@kbn/config'; import type { CustomBranding } from '@kbn/core-custom-branding-common'; +import type { DarkModeValue } from '@kbn/core-ui-settings-common'; /** @internal */ export interface InjectedMetadataClusterInfo { @@ -35,6 +36,16 @@ export interface InjectedMetadataExternalUrlPolicy { protocol?: string; } +/** @internal */ +export interface InjectedMetadataTheme { + darkMode: DarkModeValue; + version: ThemeVersion; + stylesheetPaths: { + default: string[]; + dark: string[]; + }; +} + /** @internal */ export interface InjectedMetadata { version: string; @@ -53,10 +64,7 @@ export interface InjectedMetadata { i18n: { translationsUrl: string; }; - theme: { - darkMode: boolean; - version: ThemeVersion; - }; + theme: InjectedMetadataTheme; csp: { warnLegacyBrowsers: boolean; }; diff --git a/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts b/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts index 8a5d2e4c7377b..2c5c3774041ab 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/render_utils.test.ts @@ -6,14 +6,14 @@ * Side Public License, v 1. */ -import { getStylesheetPaths } from './render_utils'; +import { getDarkModeStylesheetPaths } from './render_utils'; describe('getStylesheetPaths', () => { describe('when darkMode is `true`', () => { describe('when themeVersion is `v8`', () => { it('returns the correct list', () => { expect( - getStylesheetPaths({ + getDarkModeStylesheetPaths({ darkMode: true, themeVersion: 'v8', baseHref: '/base-path', @@ -33,7 +33,7 @@ describe('getStylesheetPaths', () => { describe('when themeVersion is `v8`', () => { it('returns the correct list', () => { expect( - getStylesheetPaths({ + getDarkModeStylesheetPaths({ darkMode: false, themeVersion: 'v8', baseHref: '/base-path', diff --git a/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts b/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts index 6f74320098b16..0093725b78da6 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/render_utils.ts @@ -25,33 +25,42 @@ export const getSettingValue = ( export const getBundlesHref = (baseHref: string, buildNr: string): string => `${baseHref}/${buildNr}/bundles`; -export const getStylesheetPaths = ({ - themeVersion, - darkMode, +export const getCommonStylesheetPaths = ({ baseHref, buildNum, }: { - themeVersion: UiSharedDepsNpm.ThemeVersion; - darkMode: boolean; buildNum: number; baseHref: string; }) => { const bundlesHref = getBundlesHref(baseHref, String(buildNum)); - return [ - ...(darkMode - ? [ - `${bundlesHref}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.darkCssDistFilename( - themeVersion - )}`, - `${bundlesHref}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`, - `${baseHref}/ui/legacy_dark_theme.min.css`, - ] - : [ - `${bundlesHref}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.lightCssDistFilename( - themeVersion - )}`, - `${bundlesHref}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`, - `${baseHref}/ui/legacy_light_theme.min.css`, - ]), - ]; + return [`${bundlesHref}/kbn-ui-shared-deps-src/${UiSharedDepsSrc.cssDistFilename}`]; }; + +export const getDarkModeStylesheetPaths = + ({ + themeVersion, + baseHref, + buildNum, + }: { + themeVersion: UiSharedDepsNpm.ThemeVersion; + buildNum: number; + baseHref: string; + }) => + ({ darkMode }: { darkMode: boolean }) => { + const bundlesHref = getBundlesHref(baseHref, String(buildNum)); + return [ + ...(darkMode + ? [ + `${bundlesHref}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.darkCssDistFilename( + themeVersion + )}`, + `${baseHref}/ui/legacy_dark_theme.min.css`, + ] + : [ + `${bundlesHref}/kbn-ui-shared-deps-npm/${UiSharedDepsNpm.lightCssDistFilename( + themeVersion + )}`, + `${baseHref}/ui/legacy_light_theme.min.css`, + ]), + ]; + }; diff --git a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx index f5bb1f5fa115f..8fa33e6e2201d 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/rendering_service.tsx @@ -18,7 +18,7 @@ import type { KibanaRequest, HttpAuth } from '@kbn/core-http-server'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; import type { UiPlugins } from '@kbn/core-plugins-base-server-internal'; import { CustomBranding } from '@kbn/core-custom-branding-common'; -import { UserProvidedValues } from '@kbn/core-ui-settings-common'; +import { UserProvidedValues, DarkModeValue } from '@kbn/core-ui-settings-common'; import { Template } from './views'; import { IRenderOptions, @@ -29,7 +29,11 @@ import { RenderingMetadata, } from './types'; import { registerBootstrapRoute, bootstrapRendererFactory } from './bootstrap'; -import { getSettingValue, getStylesheetPaths } from './render_utils'; +import { + getSettingValue, + getCommonStylesheetPaths, + getDarkModeStylesheetPaths, +} from './render_utils'; import { filterUiPlugins } from './filter_ui_plugins'; import type { InternalRenderingRequestHandlerContext } from './internal_types'; @@ -42,6 +46,8 @@ type RenderOptions = userSettings?: never; }); +const themeVersion: ThemeVersion = 'v8'; + /** @internal */ export class RenderingService { constructor(private readonly coreContext: CoreContext) {} @@ -160,31 +166,36 @@ export class RenderingService { // swallow error } - let userSettingDarkMode: boolean | undefined; - - if (!isAnonymousPage) { - userSettingDarkMode = await userSettings?.getUserSettingDarkMode(request); - } - - let darkMode: boolean; + // dark mode + const userSettingDarkMode = isAnonymousPage + ? undefined + : await userSettings?.getUserSettingDarkMode(request); const isThemeOverridden = settings.user['theme:darkMode']?.isOverridden ?? false; + let darkMode: DarkModeValue; if (userSettingDarkMode !== undefined && !isThemeOverridden) { darkMode = userSettingDarkMode; } else { - darkMode = getSettingValue('theme:darkMode', settings, Boolean); + darkMode = getSettingValue( + 'theme:darkMode', + settings, + (v) => v as DarkModeValue + ); } + // end dark mode - const themeVersion: ThemeVersion = 'v8'; - - const stylesheetPaths = getStylesheetPaths({ - darkMode, + const themeStylesheetPaths = getDarkModeStylesheetPaths({ themeVersion, baseHref: staticAssetsHrefBase, buildNum, }); + const commonStylesheetPaths = getCommonStylesheetPaths({ + baseHref: staticAssetsHrefBase, + buildNum, + }); + const filteredPlugins = filterUiPlugins({ uiPlugins, isAnonymousPage }); const bootstrapScript = isAnonymousPage ? 'bootstrap-anonymous.js' : 'bootstrap.js'; const metadata: RenderingMetadata = { @@ -195,7 +206,7 @@ export class RenderingService { locale: i18n.getLocale(), darkMode, themeVersion, - stylesheetPaths, + stylesheetPaths: commonStylesheetPaths, customBranding: { faviconSVG: branding?.faviconSVG, faviconPNG: branding?.faviconPNG, @@ -220,6 +231,10 @@ export class RenderingService { theme: { darkMode, version: themeVersion, + stylesheetPaths: { + default: themeStylesheetPaths({ darkMode: false }), + dark: themeStylesheetPaths({ darkMode: true }), + }, }, customBranding: { logo: branding?.logo, diff --git a/packages/core/rendering/core-rendering-server-internal/src/types.ts b/packages/core/rendering/core-rendering-server-internal/src/types.ts index 60dab17a20142..8146bbe8bb0cd 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/types.ts +++ b/packages/core/rendering/core-rendering-server-internal/src/types.ts @@ -16,6 +16,7 @@ import type { } from '@kbn/core-http-server-internal'; import type { InternalElasticsearchServiceSetup } from '@kbn/core-elasticsearch-server-internal'; import type { InternalStatusServiceSetup } from '@kbn/core-status-server-internal'; +import type { DarkModeValue } from '@kbn/core-ui-settings-common'; import type { IUiSettingsClient } from '@kbn/core-ui-settings-server'; import type { UiPlugins } from '@kbn/core-plugins-base-server-internal'; import type { InternalCustomBrandingSetup } from '@kbn/core-custom-branding-server-internal'; @@ -29,7 +30,7 @@ export interface RenderingMetadata { bootstrapScriptUrl: string; i18n: typeof i18n.translate; locale: string; - darkMode: boolean; + darkMode: DarkModeValue; themeVersion: ThemeVersion; stylesheetPaths: string[]; injectedMetadata: InjectedMetadata; diff --git a/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx b/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx index 64c2941a280f8..358c976ab3af8 100644 --- a/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx +++ b/packages/core/rendering/core-rendering-server-internal/src/views/styles.tsx @@ -16,7 +16,7 @@ interface Props { export const Styles: FC = ({ darkMode, stylesheetPaths }) => { return ( <> - + {stylesheetPaths.map((path) => ( ))} @@ -161,3 +161,14 @@ const InlineStyles: FC<{ darkMode: boolean }> = ({ darkMode }) => { ); /* eslint-enable react/no-danger */ }; + + +const toBoolean = (val: string | boolean): boolean => { + if (val === true || val === 'true') { + return true; + } + if (val === false || val === 'false') { + return false; + } + return Boolean(val); +}; diff --git a/packages/core/theme/core-theme-browser-internal/src/theme_service.ts b/packages/core/theme/core-theme-browser-internal/src/theme_service.ts index 58ff963c387f9..c3d1c49516206 100644 --- a/packages/core/theme/core-theme-browser-internal/src/theme_service.ts +++ b/packages/core/theme/core-theme-browser-internal/src/theme_service.ts @@ -6,8 +6,9 @@ * Side Public License, v 1. */ -import { Subject, Observable, of } from 'rxjs'; +import { Subject, ReplaySubject } from 'rxjs'; import { shareReplay, takeUntil } from 'rxjs/operators'; +import type { InjectedMetadataTheme } from '@kbn/core-injected-metadata-common-internal'; import type { InternalInjectedMetadataSetup } from '@kbn/core-injected-metadata-browser-internal'; import type { CoreTheme, ThemeServiceSetup, ThemeServiceStart } from '@kbn/core-theme-browser'; @@ -18,12 +19,25 @@ export interface ThemeServiceSetupDeps { /** @internal */ export class ThemeService { - private theme$?: Observable; + private theme$ = new ReplaySubject(1); private stop$ = new Subject(); + private themeMetadata?: InjectedMetadataTheme; + private stylesheets: HTMLLinkElement[] = []; + public setup({ injectedMetadata }: ThemeServiceSetupDeps): ThemeServiceSetup { const theme = injectedMetadata.getTheme(); - this.theme$ = of({ darkMode: theme.darkMode }); + this.themeMetadata = theme; + + if (theme.darkMode === 'system' && browsersSupportsPrefersColorScheme()) { + const darkMode = systemIsDark(); + this.applyTheme(darkMode); + onSystemPrefersColorSchemeChange((mode) => this.applyTheme(mode)); + } else { + // if browser doesn't support required capabilities we fallback to default theme + const darkMode = theme.darkMode === 'system' ? false : toBoolean(theme.darkMode); + this.applyTheme(darkMode); + } return { theme$: this.theme$.pipe(takeUntil(this.stop$), shareReplay(1)), @@ -43,4 +57,60 @@ export class ThemeService { public stop() { this.stop$.next(); } + + private applyTheme(darkMode: boolean) { + this.stylesheets.forEach((stylesheet) => { + stylesheet.remove(); + }); + this.stylesheets = []; + const newStylesheets = darkMode + ? this.themeMetadata!.stylesheetPaths.dark + : this.themeMetadata!.stylesheetPaths.default; + + newStylesheets.forEach((stylesheet) => { + this.stylesheets.push(createStyleSheet({ href: stylesheet })); + }); + + this.theme$.next({ darkMode }); + } } + +const createStyleSheet = ({ href }: { href: string }) => { + const head = document.getElementsByTagName('head')[0]; + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = href; + link.media = 'all'; + head.appendChild(link); + return link; +}; + +const systemIsDark = (): boolean => { + return window.matchMedia('(prefers-color-scheme: dark)').matches; +}; + +const onSystemPrefersColorSchemeChange = (handler: (darkMode: boolean) => void) => { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + handler(e.matches); + }); +}; + +const browsersSupportsPrefersColorScheme = (): boolean => { + try { + const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + return matchMedia.matches !== undefined && matchMedia.addEventListener !== undefined; + } catch (e) { + return false; + } +}; + +const toBoolean = (val: string | boolean): boolean => { + if (val === true || val === 'true') { + return true; + } + if (val === false || val === 'false') { + return false; + } + return Boolean(val); +}; diff --git a/packages/core/ui-settings/core-ui-settings-common/index.ts b/packages/core/ui-settings/core-ui-settings-common/index.ts index 02604e122a2ae..505eceb226e1b 100644 --- a/packages/core/ui-settings/core-ui-settings-common/index.ts +++ b/packages/core/ui-settings/core-ui-settings-common/index.ts @@ -14,5 +14,6 @@ export type { UserProvidedValues, UiSettingsScope, } from './src/ui_settings'; +export type { DarkModeValue } from './src/dark_mode'; export { TIMEZONE_OPTIONS } from './src/timezones'; diff --git a/packages/core/ui-settings/core-ui-settings-common/src/dark_mode.ts b/packages/core/ui-settings/core-ui-settings-common/src/dark_mode.ts new file mode 100644 index 0000000000000..13ec06afccf3d --- /dev/null +++ b/packages/core/ui-settings/core-ui-settings-common/src/dark_mode.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +/** + * The list of possible values for the dark mode UI setting. + * - false: dark mode is disabled + * - true: dark mode is enabled + * - "system": dark mode will follow the user system preference. + */ +export type DarkModeValue = true | false | 'system'; diff --git a/packages/core/ui-settings/core-ui-settings-server-internal/src/settings/theme.ts b/packages/core/ui-settings/core-ui-settings-server-internal/src/settings/theme.ts index 1cece7db0bfed..30176655a677c 100644 --- a/packages/core/ui-settings/core-ui-settings-server-internal/src/settings/theme.ts +++ b/packages/core/ui-settings/core-ui-settings-server-internal/src/settings/theme.ts @@ -50,8 +50,16 @@ export const getThemeSettings = ( description: i18n.translate('core.ui_settings.params.darkModeText', { defaultMessage: `Enable a dark mode for the Kibana UI. A page refresh is required for the setting to be applied.`, }), + type: 'select', + options: ['true', 'false', 'system'], + optionLabels: { + true: 'Enabled', + false: 'Disabled', + system: 'Automatic', + }, + // TODO: should no longer need reload in the end requiresPageReload: true, - schema: schema.boolean(), + schema: schema.oneOf([schema.boolean(), schema.literal('system')]), }, /** * Theme is sticking around as there are still a number of places reading it and