From 972bcbe3764e25a2fdc0e852e3235537586f95d3 Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Thu, 4 Dec 2025 10:43:01 +1100 Subject: [PATCH 1/4] Add theme and variant support to SpecialError page --- .../special-error/app/components/App.jsx | 3 +- .../pages/special-error/app/index.js | 13 ++-- .../app/providers/ThemeProvider.js | 58 ++++++++++++++++ .../special-error/app/styles/variables.css | 67 ++++++++++++++----- .../special-error-theme.spec.js | 54 +++++++++++++++ .../integration-tests/special-error.js | 44 +++++++++++- .../messages/initialSetup.response.json | 6 ++ .../messages/onThemeUpdate.subscribe.json | 13 ++++ .../messages/types/browser-theme.json | 8 +++ .../messages/types/theme-variant.json | 14 ++++ .../pages/special-error/public/index.html | 7 ++ .../pages/special-error/src/index.js | 9 +++ .../pages/special-error/src/mock-transport.js | 20 +++++- .../special-error/types/special-error.ts | 20 +++++- special-pages/playwright.config.js | 3 + 15 files changed, 312 insertions(+), 27 deletions(-) create mode 100644 special-pages/pages/special-error/app/providers/ThemeProvider.js create mode 100644 special-pages/pages/special-error/integration-tests/special-error-theme.spec.js create mode 100644 special-pages/pages/special-error/messages/onThemeUpdate.subscribe.json create mode 100644 special-pages/pages/special-error/messages/types/browser-theme.json create mode 100644 special-pages/pages/special-error/messages/types/theme-variant.json diff --git a/special-pages/pages/special-error/app/components/App.jsx b/special-pages/pages/special-error/app/components/App.jsx index 3baca43cd8..c1de7a9303 100644 --- a/special-pages/pages/special-error/app/components/App.jsx +++ b/special-pages/pages/special-error/app/components/App.jsx @@ -48,7 +48,6 @@ function PageTitle() { export function App() { const { messaging } = useMessaging(); - const { isDarkMode } = useEnv(); /** * @param {Error} error @@ -60,7 +59,7 @@ export function App() { } return ( -
+
didCatch(error)} fallback={}> diff --git a/special-pages/pages/special-error/app/index.js b/special-pages/pages/special-error/app/index.js index 4816b1086a..a5f8a7ec87 100644 --- a/special-pages/pages/special-error/app/index.js +++ b/special-pages/pages/special-error/app/index.js @@ -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'; @@ -65,11 +66,13 @@ export async function init(messaging, baseEnvironment) { - - - - - + + + + + + + , diff --git a/special-pages/pages/special-error/app/providers/ThemeProvider.js b/special-pages/pages/special-error/app/providers/ThemeProvider.js new file mode 100644 index 0000000000..3e9e261380 --- /dev/null +++ b/special-pages/pages/special-error/app/providers/ThemeProvider.js @@ -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 + useEffect(() => { + document.body.dataset.theme = theme; + }, [theme]); + useEffect(() => { + document.body.dataset.themeVariant = themeVariant; + }, [themeVariant]); + + return {children}; +} + +export function useTheme() { + return useContext(ThemeContext); +} diff --git a/special-pages/pages/special-error/app/styles/variables.css b/special-pages/pages/special-error/app/styles/variables.css index 416ca60eba..04d1f11fe8 100644 --- a/special-pages/pages/special-error/app/styles/variables.css +++ b/special-pages/pages/special-error/app/styles/variables.css @@ -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); @@ -11,23 +15,54 @@ --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); +} - --container-bg: var(--color-gray-90); - --advanced-info-bg: #2f2f2f; +body[data-theme='dark'][data-platform-name='ios'][data-theme-variant='default'] { + --theme-background-color: #222; +} - --visit-site-color: var(--color-gray-40); - } +/* Theme variant background colors */ +body[data-theme-variant='coolGray'] { + --default-light-background-color: #d2d5e3; + --default-dark-background-color: #2b2f45; +} - [data-platform-name="ios"] { - --theme-background-color: #222; - } +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; +} +body[data-theme-variant='violet'] { + --default-light-background-color: #e7e4f5; + --default-dark-background-color: #2e2158; +} + +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; +} diff --git a/special-pages/pages/special-error/integration-tests/special-error-theme.spec.js b/special-pages/pages/special-error/integration-tests/special-error-theme.spec.js new file mode 100644 index 0000000000..84bcfe50a1 --- /dev/null +++ b/special-pages/pages/special-error/integration-tests/special-error-theme.spec.js @@ -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' }); + }); +}); diff --git a/special-pages/pages/special-error/integration-tests/special-error.js b/special-pages/pages/special-error/integration-tests/special-error.js index b6a398d7aa..9ffb3303d3 100644 --- a/special-pages/pages/special-error/integration-tests/special-error.js +++ b/special-pages/pages/special-error/integration-tests/special-error.js @@ -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} [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}`); } @@ -67,6 +75,10 @@ export class SpecialErrorPage { initialSetup.locale = locale; } + if (additional) { + Object.assign(initialSetup, additional); + } + this.mocks.defaultResponses({ initialSetup, }); @@ -396,4 +408,34 @@ export class SpecialErrorPage { }), ); } + + /** + * @param {object} params + * @param {string} params.hex + * @returns {Promise} + */ + 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); + } } diff --git a/special-pages/pages/special-error/messages/initialSetup.response.json b/special-pages/pages/special-error/messages/initialSetup.response.json index d640d336cc..dd5a889651 100644 --- a/special-pages/pages/special-error/messages/initialSetup.response.json +++ b/special-pages/pages/special-error/messages/initialSetup.response.json @@ -113,6 +113,12 @@ "localeStrings": { "type": "string", "description": "Optional locale-specific strings" + }, + "theme": { + "$ref": "./types/browser-theme.json" + }, + "themeVariant": { + "$ref": "./types/theme-variant.json" } } } diff --git a/special-pages/pages/special-error/messages/onThemeUpdate.subscribe.json b/special-pages/pages/special-error/messages/onThemeUpdate.subscribe.json new file mode 100644 index 0000000000..8af166e6f3 --- /dev/null +++ b/special-pages/pages/special-error/messages/onThemeUpdate.subscribe.json @@ -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" + } + } +} diff --git a/special-pages/pages/special-error/messages/types/browser-theme.json b/special-pages/pages/special-error/messages/types/browser-theme.json new file mode 100644 index 0000000000..68f0ecc065 --- /dev/null +++ b/special-pages/pages/special-error/messages/types/browser-theme.json @@ -0,0 +1,8 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Browser Theme", + "enum": [ + "light", + "dark" + ] +} diff --git a/special-pages/pages/special-error/messages/types/theme-variant.json b/special-pages/pages/special-error/messages/types/theme-variant.json new file mode 100644 index 0000000000..c66dee27e4 --- /dev/null +++ b/special-pages/pages/special-error/messages/types/theme-variant.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Theme Variant", + "enum": [ + "default", + "coolGray", + "slateBlue", + "green", + "violet", + "rose", + "orange", + "desert" + ] +} diff --git a/special-pages/pages/special-error/public/index.html b/special-pages/pages/special-error/public/index.html index c276790f3e..78d95746a4 100644 --- a/special-pages/pages/special-error/public/index.html +++ b/special-pages/pages/special-error/public/index.html @@ -4,6 +4,13 @@ Error + diff --git a/special-pages/pages/special-error/src/index.js b/special-pages/pages/special-error/src/index.js index b180f0bc04..8c9d0fbf2a 100644 --- a/special-pages/pages/special-error/src/index.js +++ b/special-pages/pages/special-error/src/index.js @@ -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); diff --git a/special-pages/pages/special-error/src/mock-transport.js b/special-pages/pages/special-error/src/mock-transport.js index 1f334d32e7..8c786d34a2 100644 --- a/special-pages/pages/special-error/src/mock-transport.js +++ b/special-pages/pages/special-error/src/mock-transport.js @@ -39,12 +39,28 @@ export function mockTransport() { }; } - return Promise.resolve({ + /** @type {import('../types/special-error.js').InitialSetupResponse} */ + const response = { env: 'development', locale: 'en', platform, errorData, - }); + }; + + // Allow theme override via URL params for testing + const themeParam = searchParams.get('theme'); + if (themeParam === 'light' || themeParam === 'dark') { + response.theme = /** @type {import('../types/special-error.js').BrowserTheme} */ (themeParam); + } + + // Allow themeVariant override via URL params for testing + const themeVariantParam = searchParams.get('themeVariant'); + const validVariants = ['default', 'coolGray', 'slateBlue', 'green', 'violet', 'rose', 'orange', 'desert']; + if (themeVariantParam && validVariants.includes(themeVariantParam)) { + response.themeVariant = /** @type {import('../types/special-error.js').ThemeVariant} */ (themeVariantParam); + } + + return Promise.resolve(response); } default: return Promise.resolve(null); diff --git a/special-pages/pages/special-error/types/special-error.ts b/special-pages/pages/special-error/types/special-error.ts index c64833a626..6d499613a4 100644 --- a/special-pages/pages/special-error/types/special-error.ts +++ b/special-pages/pages/special-error/types/special-error.ts @@ -6,6 +6,9 @@ * @module SpecialError Messages */ +export type BrowserTheme = "light" | "dark"; +export type ThemeVariant = "default" | "coolGray" | "slateBlue" | "green" | "violet" | "rose" | "orange" | "desert"; + /** * Requests, Notifications and Subscriptions from the SpecialError feature */ @@ -17,6 +20,7 @@ export interface SpecialErrorMessages { | ReportPageExceptionNotification | VisitSiteNotification; requests: InitialSetupRequest; + subscriptions: OnThemeUpdateSubscription; } /** * Generated from @see "../messages/advancedInfo.notify.json" @@ -74,6 +78,8 @@ export interface InitialSetupResponse { * Optional locale-specific strings */ localeStrings?: string; + theme?: BrowserTheme; + themeVariant?: ThemeVariant; } export interface MaliciousSite { kind: "phishing" | "malware" | "scam"; @@ -100,10 +106,22 @@ export interface SSLWrongHost { domain: string; eTldPlus1: string; } +/** + * Generated from @see "../messages/onThemeUpdate.subscribe.json" + */ +export interface OnThemeUpdateSubscription { + subscriptionEvent: "onThemeUpdate"; + params: OnThemeUpdateSubscribe; +} +export interface OnThemeUpdateSubscribe { + theme: BrowserTheme; + themeVariant: ThemeVariant; +} declare module "../src/index.js" { export interface SpecialErrorPage { notify: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['notify'], - request: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['request'] + request: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['request'], + subscribe: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['subscribe'] } } \ No newline at end of file diff --git a/special-pages/playwright.config.js b/special-pages/playwright.config.js index 96ba3a15c8..8e826b76f5 100644 --- a/special-pages/playwright.config.js +++ b/special-pages/playwright.config.js @@ -11,6 +11,7 @@ export default defineConfig({ 'onboarding.spec.js', 'special-error.spec.js', 'special-error-screenshots.spec.js', + 'special-error-theme.spec.js', ], use: { ...devices['Desktop Edge'], @@ -57,6 +58,7 @@ export default defineConfig({ 'release-notes.spec.js', 'special-error.spec.js', 'special-error-screenshots.spec.js', + 'special-error-theme.spec.js', ], use: { ...devices['Desktop Safari'], @@ -89,6 +91,7 @@ export default defineConfig({ 'duckplayer-screenshots.spec.js', 'special-error.spec.js', 'special-error-screenshots.spec.js', + 'special-error-theme.spec.js', ], use: { ...devices['iPhone 14'], From 35937bac70c6f6dc29859b4756033f420eb740b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 4 Dec 2025 00:35:27 +0000 Subject: [PATCH 2/4] Refactor: Update comment for theme variant background colors Co-authored-by: randerson --- special-pages/pages/special-error/app/styles/variables.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/special-pages/pages/special-error/app/styles/variables.css b/special-pages/pages/special-error/app/styles/variables.css index 04d1f11fe8..41fb7cc6f8 100644 --- a/special-pages/pages/special-error/app/styles/variables.css +++ b/special-pages/pages/special-error/app/styles/variables.css @@ -31,7 +31,7 @@ body[data-theme='dark'][data-platform-name='ios'][data-theme-variant='default'] --theme-background-color: #222; } -/* Theme variant background colors */ +/* TODO: Use colour variables from design-tokens */ body[data-theme-variant='coolGray'] { --default-light-background-color: #d2d5e3; --default-dark-background-color: #2b2f45; From bd6ac873f3fb6c450bc8924e6105a825d45ec4ec Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Thu, 4 Dec 2025 11:36:18 +1100 Subject: [PATCH 3/4] Apply suggestion from @noisysocks --- special-pages/pages/special-error/app/styles/variables.css | 1 + 1 file changed, 1 insertion(+) diff --git a/special-pages/pages/special-error/app/styles/variables.css b/special-pages/pages/special-error/app/styles/variables.css index 41fb7cc6f8..b1f662082b 100644 --- a/special-pages/pages/special-error/app/styles/variables.css +++ b/special-pages/pages/special-error/app/styles/variables.css @@ -32,6 +32,7 @@ body[data-theme='dark'][data-platform-name='ios'][data-theme-variant='default'] } /* TODO: Use colour variables from design-tokens */ + body[data-theme-variant='coolGray'] { --default-light-background-color: #d2d5e3; --default-dark-background-color: #2b2f45; From c6fcae8c8d8fe69a5b94be0eb4d92c1c7be023cd Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Thu, 4 Dec 2025 12:05:37 +1100 Subject: [PATCH 4/4] Fix ?display=components not picking up theming --- special-pages/pages/special-error/app/index.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/special-pages/pages/special-error/app/index.js b/special-pages/pages/special-error/app/index.js index a5f8a7ec87..a67f325cbf 100644 --- a/special-pages/pages/special-error/app/index.js +++ b/special-pages/pages/special-error/app/index.js @@ -82,11 +82,13 @@ export async function init(messaging, baseEnvironment) { render( - - - - - + + + + + + + , root,