Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement dark mode #9181

Merged
merged 6 commits into from
Oct 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions dotcom-rendering/src/components/ArticlePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { DecideLayout } from '../layouts/DecideLayout';
import { buildAdTargeting } from '../lib/ad-targeting';
import { filterABTestSwitches } from '../model/enhance-switches';
import type { NavType } from '../model/extract-nav';
import { paletteDeclarations } from '../palette';
import type { DCRArticle } from '../types/frontend';
import type { RenderingTarget } from '../types/renderingTarget';
import { AlreadyVisited } from './AlreadyVisited.importable';
Expand Down Expand Up @@ -57,6 +58,19 @@ export const ArticlePage = (props: WebProps | AppProps) => {
<StrictMode>
<Global
styles={css`
:root {
/* Light palette is default on all platforms */
${paletteDeclarations(format, 'light')}

/* Dark palette only for apps and only if switch turned on */
${article.config.switches.darkModeInApps && renderingTarget === 'Apps'
? css`
@media (prefers-color-scheme: dark) {
${paletteDeclarations(format, 'dark')}
}
`
: ''}
}
/* Crude but effective mechanism. Specific components may need to improve on this behaviour. */
/* The not(.src...) selector is to work with Source's FocusStyleManager. */
*:focus {
Expand Down
61 changes: 61 additions & 0 deletions dotcom-rendering/src/components/HeadlineExample.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// ----- Imports ----- //

import { css } from '@emotion/react';
import { ArticleDesign, ArticleDisplay, ArticlePillar } from '@guardian/libs';
import type { Decorator, Meta, StoryObj } from '@storybook/react';
import { paletteDeclarations } from '../palette';
import { HeadlineExample } from './HeadlineExample';

// ----- Meta ----- //

const meta: Meta<typeof HeadlineExample> = {
title: 'components/HeadlineExample',
component: HeadlineExample,
};

export default meta;

// ----- Decorators ----- //

/**
* Creates storybook decorator used to wrap components in an element
* containing the light or dark mode palette colours.
*
* @param colourScheme Choose whether to use the light or darPerformanceNavigation.type palette.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love PerformanceNavigation.type as much as the next person, but not sure what it’s doing there…

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are you SO eagle eyed

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

great spot

* @returns A decorator that wraps the component in a `div` containing the
* palette colours as CSS custom properties.
*/
const colourSchemeDecorator =
(colourScheme: 'light' | 'dark') =>
(format: ArticleFormat): Decorator =>
(Story) =>
(
<div css={css(paletteDeclarations(format, colourScheme))}>
<Story />
</div>
);

const lightMode = colourSchemeDecorator('light');
const darkMode = colourSchemeDecorator('dark');

// ----- Stories ----- //

type Story = StoryObj<typeof HeadlineExample>;

const articleFormat: ArticleFormat = {
design: ArticleDesign.Standard,
display: ArticleDisplay.Standard,
theme: ArticlePillar.News,
};

export const LightHeadline: Story = {
args: {
text: 'A short example headline',
},
decorators: [lightMode(articleFormat)],
};

export const DarkHeadline: Story = {
args: LightHeadline.args,
decorators: [darkMode(articleFormat)],
};
16 changes: 16 additions & 0 deletions dotcom-rendering/src/components/HeadlineExample.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// ----- Imports ----- //

import { css } from '@emotion/react';
import { headline } from '@guardian/source-foundations';
import { palette } from '../palette';

// ----- Component ----- //

export const HeadlineExample = ({ text }: { text: string }) => {
const styles = css`
color: ${palette('--headline-colour')};
background-color: ${palette('--headline-background-colour')};
${headline.large()}
`;
return <h1 css={styles}>{text}</h1>;
};
175 changes: 175 additions & 0 deletions dotcom-rendering/src/palette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// ----- Imports ----- //

import { ArticleDesign, type ArticleFormat } from '@guardian/libs';
import { palette as sourcePalette } from '@guardian/source-foundations';

// ----- Palette Functions ----- //

const headlineColourLight = ({ design }: ArticleFormat): string => {
switch (design) {
case ArticleDesign.Feature:
return sourcePalette.news[300];
default:
return sourcePalette.neutral[10];
}
};

const headlineColourDark = ({ design }: ArticleFormat): string => {
switch (design) {
case ArticleDesign.Feature:
return sourcePalette.news[600];
default:
return sourcePalette.neutral[97];
}
};

const headlineBackgroundColourLight = ({ design }: ArticleFormat): string => {
switch (design) {
case ArticleDesign.LiveBlog:
return sourcePalette.news[400];
default:
return sourcePalette.neutral[100];
}
};

const headlineBackgroundColourDark = ({ design }: ArticleFormat): string => {
switch (design) {
case ArticleDesign.LiveBlog:
return sourcePalette.news[200];
default:
return sourcePalette.neutral[7];
}
};

// ----- Palette ----- //

/**
* A template literal type used to make sure the keys of the palette use the
* correct CSS custom property syntax.
*/
type CSSCustomProperty = `--${string}`;
/**
* Ensures that all palette functions provide the same API, deriving a palette
* colour from an {@linkcode ArticleFormat}.
*/
type PaletteFunction = (f: ArticleFormat) => string;
/**
* Used to validate that the palette object always has the correct shape,
* without changing its type.
*/
type PaletteColours = Record<
CSSCustomProperty,
{
light: PaletteFunction;
dark: PaletteFunction;
}
>;

/**
* Maps palette colour names (which are also CSS custom property names) to
* a pair of palette functions, which can be used to derive both light and dark
* mode colours from an {@linkcode ArticleFormat}.
*
* This is not accessed directly in components; the {@linkcode palette} function
* is used instead.
*/
const paletteColours = {
'--headline-colour': {
light: headlineColourLight,
dark: headlineColourDark,
},
'--headline-background-colour': {
light: headlineBackgroundColourLight,
dark: headlineBackgroundColourDark,
},
} satisfies PaletteColours;

/**
* A union of all the keys of the palette object. In other words, all the
* possible colours that can be chosen.
*/
type ColourName = keyof typeof paletteColours;

/**
* Looks up a palette colour by name. Retrieves a CSS value for the specified
* colour, for use in CSS declarations. See the examples for how this is
* commonly used with our Emotion-based styles.
*
* @param a The name of a palette colour; for example `--headline-colour`.
* @returns A CSS `var` function call; for example `var(--headline-colour)`.
* @example
* const styles = css`
* color: ${palette('--headline-colour')};
* background-color: ${palette('--headline-background-colour')};
* `;
*/
const palette = (colour: ColourName): string => `var(${colour})`;

/**
* Builds a list of CSS custom property declarations representing colours. These
* can be used to set up the palette on any element, and then retrieved to apply
* styles via the {@linkcode palette} function. See the examples for ways the
* palette could be set up.
*
* @param format The `ArticleFormat` of the current article.
* @param colourScheme Get declarations for either `light` or `dark` mode.
* @returns A set of CSS custom property declarations for palette colours,
* in string format. For example:
* ```
* [ '--headline-colour: #1a1a1a;', '--headline-background-colour: #ffffff;' ]
* ```
* @example
* <caption>Create a single stylesheet to handle both colour schemes.</caption>
* const paletteStyles = css`
* :root {
* ${paletteDeclarations(format, 'light').join('\n')}
* }
*
* (@)media (prefers-color-scheme: dark) {
* :root {
* ${paletteDeclarations(format, 'dark').join('\n')}
* }
* }
* `;
* @example
* <caption>Load separate stylesheets based on user preference.</caption>
* // Use to build a file called 'light.css'.
* const lightPalette = css`
* :root {
* ${paletteDeclarations(format, 'light').join('\n')}
* }
* `;
* // Use to build a file called 'dark.css'.
* const darkPalette = css`
* :root {
* ${paletteDeclarations(format, 'dark').join('\n')}
* }
* `;
*
* const stylesheets = (
* <>
* <link
* media="(prefers-color-scheme: light)"
* rel="stylesheet"
* href="light.css"
* />
* <link
* media="(prefers-color-scheme: dark)"
* rel="stylesheet"
* href="dark.css"
* />
* </>
* );
*/
const paletteDeclarations = (
format: ArticleFormat,
colourScheme: 'light' | 'dark',
): string[] =>
Object.entries(paletteColours).map(
([colourName, colour]) =>
`${colourName}: ${colour[colourScheme](format)};`,
);

// ----- Exports ----- //

export { palette, paletteDeclarations };
Loading