Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
3 changes: 1 addition & 2 deletions special-pages/pages/special-error/app/components/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ function PageTitle() {

export function App() {
const { messaging } = useMessaging();
const { isDarkMode } = useEnv();

/**
* @param {Error} error
Expand All @@ -60,7 +59,7 @@ export function App() {
}

return (
<main className={styles.main} data-theme={isDarkMode ? 'dark' : 'light'}>
<main className={styles.main}>
<PageTitle />
<ErrorBoundary didCatch={({ error }) => didCatch(error)} fallback={<ErrorFallback />}>
<SpecialErrorView />
Expand Down
13 changes: 8 additions & 5 deletions special-pages/pages/special-error/app/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Components } from './components/Components.jsx';
import enStrings from '../public/locales/en/special-error.json';
import { TranslationProvider } from '../../../shared/components/TranslationsProvider.js';
import { MessagingProvider } from './providers/MessagingProvider.js';
import { ThemeProvider } from './providers/ThemeProvider.js';
import { SettingsProvider } from './providers/SettingsProvider.jsx';
import { SpecialErrorProvider } from './providers/SpecialErrorProvider.js';
import { callWithRetry } from '../../../shared/call-with-retry.js';
Expand Down Expand Up @@ -65,11 +66,13 @@ export async function init(messaging, baseEnvironment) {
<UpdateEnvironment search={window.location.search} />
<TranslationProvider translationObject={strings} fallback={enStrings} textLength={environment.textLength}>
<MessagingProvider messaging={messaging}>
<SettingsProvider settings={settings}>
<SpecialErrorProvider specialError={specialError}>
<App />
</SpecialErrorProvider>
</SettingsProvider>
<ThemeProvider initialTheme={init.theme} initialThemeVariant={init.themeVariant}>
<SettingsProvider settings={settings}>
<SpecialErrorProvider specialError={specialError}>
<App />
</SpecialErrorProvider>
</SettingsProvider>
</ThemeProvider>
</MessagingProvider>
</TranslationProvider>
</EnvironmentProvider>,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { createContext, h } from 'preact';
import { useContext, useEffect, useState } from 'preact/hooks';
import { useMessaging } from './MessagingProvider.js';
import { useEnv } from '../../../../shared/components/EnvironmentProvider.js';

/**
* @typedef {import('../../types/special-error').BrowserTheme} BrowserTheme
* @typedef {import('../../types/special-error').ThemeVariant} ThemeVariant
*/

const ThemeContext = createContext({
/** @type {BrowserTheme} */
theme: 'light',
/** @type {ThemeVariant} */
themeVariant: 'default',
});

/**
* @param {object} props
* @param {import('preact').ComponentChild} props.children
* @param {BrowserTheme | undefined} props.initialTheme
* @param {ThemeVariant | undefined} props.initialThemeVariant
*/
export function ThemeProvider({ children, initialTheme, initialThemeVariant }) {
const { isDarkMode } = useEnv();
const { messaging } = useMessaging();

// Track explicit theme updates from onThemeUpdate subscription
const [explicitTheme, setExplicitTheme] = useState(/** @type {BrowserTheme | undefined} */ (undefined));
const [explicitThemeVariant, setExplicitThemeVariant] = useState(/** @type {ThemeVariant | undefined} */ (undefined));

useEffect(() => {
if (!messaging) return;
const unsubscribe = messaging.onThemeUpdate((data) => {
setExplicitTheme(data.theme);
setExplicitThemeVariant(data.themeVariant);
});
return unsubscribe;
}, [messaging]);

// Derive theme from explicit updates, initial theme, or system preference (in that order)
const theme = explicitTheme ?? initialTheme ?? (isDarkMode ? 'dark' : 'light');
const themeVariant = explicitThemeVariant ?? initialThemeVariant ?? 'default';

// Sync theme attributes to <body>
useEffect(() => {
document.body.dataset.theme = theme;
}, [theme]);
useEffect(() => {
document.body.dataset.themeVariant = themeVariant;
}, [themeVariant]);

return <ThemeContext.Provider value={{ theme, themeVariant }}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
return useContext(ThemeContext);
}
68 changes: 52 additions & 16 deletions special-pages/pages/special-error/app/styles/variables.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
:root {
/* Light theme colors */
--theme-background-color: var(--color-gray-20);
--default-light-background-color: var(--color-gray-20);
--default-dark-background-color: var(--color-gray-85);
}

body[data-theme='light'] {
--theme-background-color: var(--default-light-background-color);
--theme-text-primary-color: var(--color-black-at-84);
--link-color: var(--color-black);
--border-color: rgba(0, 0, 0, 0.1);
Expand All @@ -11,23 +15,55 @@
--visit-site-color: var(--color-black);
}

@media (prefers-color-scheme: dark) {
:root {
/* Dark theme colors */
--theme-background-color: var(--color-gray-85);
--theme-text-primary-color: var(--color-white-at-84);
--link-color: var(--color-gray-40);
--border-color: var(--color-white-at-18);
body[data-theme='dark'] {
--theme-background-color: var(--default-dark-background-color);
--theme-text-primary-color: var(--color-white-at-84);
--link-color: var(--color-gray-40);
--border-color: var(--color-white-at-18);

--container-bg: var(--color-gray-90);
--advanced-info-bg: #2f2f2f;

--visit-site-color: var(--color-gray-40);
}

body[data-theme='dark'][data-platform-name='ios'][data-theme-variant='default'] {
--theme-background-color: #222;
}

/* TODO: Use colour variables from design-tokens */

--container-bg: var(--color-gray-90);
--advanced-info-bg: #2f2f2f;
body[data-theme-variant='coolGray'] {
--default-light-background-color: #d2d5e3;
--default-dark-background-color: #2b2f45;
}

body[data-theme-variant='slateBlue'] {
--default-light-background-color: #d2e5f3;
--default-dark-background-color: #1e3347;
}

body[data-theme-variant='green'] {
--default-light-background-color: #e3eee1;
--default-dark-background-color: #203b30;
}

--visit-site-color: var(--color-gray-40);
}
body[data-theme-variant='violet'] {
--default-light-background-color: #e7e4f5;
--default-dark-background-color: #2e2158;
}

[data-platform-name="ios"] {
--theme-background-color: #222;
}
body[data-theme-variant='rose'] {
--default-light-background-color: #f8ebf5;
--default-dark-background-color: #5b194b;
}

body[data-theme-variant='orange'] {
--default-light-background-color: #fcedd8;
--default-dark-background-color: #54240c;
}

body[data-theme-variant='desert'] {
--default-light-background-color: #eee9e1;
--default-dark-background-color: #3c3833;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { test } from '@playwright/test';
import { SpecialErrorPage } from './special-error.js';

test.describe('special-error theme and theme variants', () => {
test('setting theme = dark and themeVariant via initialSetup', async ({ page }, workerInfo) => {
const sp = SpecialErrorPage.create(page, workerInfo);
await sp.openPage({ additional: { theme: 'dark', themeVariant: 'violet' } });
await sp.hasTheme('dark', 'violet');
await sp.hasBackgroundColor({ hex: '#2e2158' });
});

test('setting theme = light and themeVariant via initialSetup', async ({ page }, workerInfo) => {
const sp = SpecialErrorPage.create(page, workerInfo);
await sp.openPage({ additional: { theme: 'light', themeVariant: 'coolGray' } });
await sp.hasTheme('light', 'coolGray');
await sp.hasBackgroundColor({ hex: '#d2d5e3' });
});

test('light theme and default themeVariant when unspecified', async ({ page }, workerInfo) => {
const sp = SpecialErrorPage.create(page, workerInfo);
await sp.openPage();
await sp.hasTheme('light', 'default');
await sp.hasBackgroundColor({ hex: '#eeeeee' });
});

test('dark theme and default themeVariant when unspecified', async ({ page }, workerInfo) => {
const sp = SpecialErrorPage.create(page, workerInfo);
await sp.darkMode();
await sp.openPage();
await sp.hasTheme('dark', 'default');
const isIOS = sp.platform.name === 'ios'; // iOS has a different default background color
await sp.hasBackgroundColor({ hex: isIOS ? '#222222' : '#333333' });
});

test('changing theme to dark and themeVariant using onThemeUpdate', async ({ page }, workerInfo) => {
const sp = SpecialErrorPage.create(page, workerInfo);
await sp.openPage({ additional: { theme: 'light', themeVariant: 'desert' } });
await sp.hasTheme('light', 'desert');
await sp.hasBackgroundColor({ hex: '#eee9e1' });
await sp.acceptsThemeUpdate('dark', 'slateBlue');
await sp.hasTheme('dark', 'slateBlue');
await sp.hasBackgroundColor({ hex: '#1e3347' });
});

test('changing theme to light and themeVariant using onThemeUpdate', async ({ page }, workerInfo) => {
const sp = SpecialErrorPage.create(page, workerInfo);
await sp.openPage({ additional: { theme: 'dark', themeVariant: 'rose' } });
await sp.hasTheme('dark', 'rose');
await sp.hasBackgroundColor({ hex: '#5b194b' });
await sp.acceptsThemeUpdate('light', 'green');
await sp.hasTheme('light', 'green');
await sp.hasBackgroundColor({ hex: '#e3eee1' });
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,16 @@ export class SpecialErrorPage {
* @param {keyof sampleData} [params.errorId] - ID of the error to be mocked (see sampleData.js)
* @param {PlatformInfo['name']} [params.platformName] - platform name
* @param {string} [params.locale] - locale
* @param {Record<string, any>} [params.additional] - Optional map of key/values to add to initialSetup
*/
async openPage({ env = 'app', willThrow = false, errorId = 'ssl.expired', platformName = this.platform.name, locale } = {}) {
async openPage({
env = 'app',
willThrow = false,
errorId = 'ssl.expired',
platformName = this.platform.name,
locale,
additional,
} = {}) {
if (platformName === 'extension') {
throw new Error(`Unsupported platform ${platformName}`);
}
Expand All @@ -67,6 +75,10 @@ export class SpecialErrorPage {
initialSetup.locale = locale;
}

if (additional) {
Object.assign(initialSetup, additional);
}

this.mocks.defaultResponses({
initialSetup,
});
Expand Down Expand Up @@ -396,4 +408,34 @@ export class SpecialErrorPage {
}),
);
}

/**
* @param {object} params
* @param {string} params.hex
* @returns {Promise<void>}
*/
async hasBackgroundColor({ hex }) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const rgb = `rgb(${[r, g, b].join(', ')})`;
await expect(this.page.locator('body')).toHaveCSS('background-color', rgb, { timeout: 50 });
}

/**
* @param {import('../types/special-error.ts').BrowserTheme} theme
* @param {import('../types/special-error.ts').ThemeVariant} themeVariant
*/
async acceptsThemeUpdate(theme, themeVariant) {
await this.mocks.simulateSubscriptionMessage('onThemeUpdate', { theme, themeVariant });
}

/**
* @param {import('../types/special-error.ts').BrowserTheme} theme
* @param {import('../types/special-error.ts').ThemeVariant} themeVariant
*/
async hasTheme(theme, themeVariant) {
await expect(this.page.locator('body')).toHaveAttribute('data-theme', theme);
await expect(this.page.locator('body')).toHaveAttribute('data-theme-variant', themeVariant);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@
"localeStrings": {
"type": "string",
"description": "Optional locale-specific strings"
},
"theme": {
"$ref": "./types/browser-theme.json"
},
"themeVariant": {
"$ref": "./types/theme-variant.json"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["theme", "themeVariant"],
"properties": {
"theme": {
"$ref": "./types/browser-theme.json"
},
"themeVariant": {
"$ref": "./types/theme-variant.json"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Browser Theme",
"enum": [
"light",
"dark"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Theme Variant",
"enum": [
"default",
"coolGray",
"slateBlue",
"green",
"violet",
"rose",
"orange",
"desert"
]
}
7 changes: 7 additions & 0 deletions special-pages/pages/special-error/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
<title>Error</title>
<meta name="robots" content="noindex,nofollow">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
body {
/* Token is replaced by native browser so background isn't white while Special Error initializes */
--loading-color: $LOADING_COLOR$;
background: var(--loading-color);
}
</style>
<script src="./dist/inline.js"></script>
<link rel="stylesheet" href="./dist/index.css" />
</head>
Expand Down
9 changes: 9 additions & 0 deletions special-pages/pages/special-error/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,15 @@ export class SpecialErrorPage {
advancedInfo() {
this.messaging.notify('advancedInfo');
}

/**
* Subscribe to theme update notifications from the native layer.
* @param {(data: import('../types/special-error.ts').OnThemeUpdateSubscribe) => void} callback
* @returns {() => void} Unsubscribe function
*/
onThemeUpdate(callback) {
return this.messaging.subscribe('onThemeUpdate', callback);
}
}

const baseEnvironment = new Environment().withInjectName(document.documentElement.dataset.platform).withEnv(import.meta.env);
Expand Down
Loading