diff --git a/injected/docs/theme-color.md b/injected/docs/theme-color.md new file mode 100644 index 0000000000..9ea8a2c934 --- /dev/null +++ b/injected/docs/theme-color.md @@ -0,0 +1,30 @@ +--- +title: Theme Color Monitor +--- + +# Theme Color Monitor + +Reports the presence of the theme-color meta tag on page load. + +The theme-color meta tag is used by browsers to customize the UI color to match the website's branding. This feature reports the theme color value when it's found on initial page load. + +## Notifications + +### `themeColorStatus` +- {@link "ThemeColor Messages".ThemeColorStatusNotification} +- Sends initial theme color value on page load +- If no theme-color meta tag is found, the themeColor value will be null + +**Example** + +```json +{ + "themeColor": "#ff0000", + "documentUrl": "https://example.com" +} +``` + +## Remote Config + +### Enabled (default) +{@includeCode ../integration-test/test-pages/theme-color/config/theme-color-enabled.json} diff --git a/injected/integration-test/test-pages/favicon/config/favicon-absent.json b/injected/integration-test/test-pages/favicon/config/favicon-absent.json index fdd2c29b7e..797bffdf1f 100644 --- a/injected/integration-test/test-pages/favicon/config/favicon-absent.json +++ b/injected/integration-test/test-pages/favicon/config/favicon-absent.json @@ -1,6 +1,9 @@ { "features": { - + "themeColor": { + "state": "disabled", + "exceptions": [] + } }, "unprotectedTemporary": [] } diff --git a/injected/integration-test/test-pages/favicon/config/favicon-disabled.json b/injected/integration-test/test-pages/favicon/config/favicon-disabled.json index 842c4b57fe..b5d235ea9c 100644 --- a/injected/integration-test/test-pages/favicon/config/favicon-disabled.json +++ b/injected/integration-test/test-pages/favicon/config/favicon-disabled.json @@ -3,6 +3,10 @@ "favicon": { "state": "disabled", "exceptions": [] + }, + "themeColor": { + "state": "disabled", + "exceptions": [] } }, "unprotectedTemporary": [] diff --git a/injected/integration-test/test-pages/favicon/config/favicon-enabled.json b/injected/integration-test/test-pages/favicon/config/favicon-enabled.json index cd38719ed0..fe0749a2bb 100644 --- a/injected/integration-test/test-pages/favicon/config/favicon-enabled.json +++ b/injected/integration-test/test-pages/favicon/config/favicon-enabled.json @@ -6,6 +6,10 @@ "settings": { "monitor": true } + }, + "themeColor": { + "state": "disabled", + "exceptions": [] } }, "unprotectedTemporary": [] diff --git a/injected/integration-test/test-pages/favicon/config/favicon-monitor-disabled.json b/injected/integration-test/test-pages/favicon/config/favicon-monitor-disabled.json index 9afedd51bc..4fa41eeadc 100644 --- a/injected/integration-test/test-pages/favicon/config/favicon-monitor-disabled.json +++ b/injected/integration-test/test-pages/favicon/config/favicon-monitor-disabled.json @@ -6,6 +6,10 @@ "settings": { "monitor": false } + }, + "themeColor": { + "state": "disabled", + "exceptions": [] } }, "unprotectedTemporary": [] diff --git a/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json b/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json index 86ce1d86af..9571242daa 100644 --- a/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json +++ b/injected/integration-test/test-pages/message-bridge/config/message-bridge-disabled.json @@ -5,6 +5,10 @@ "state": "disabled", "exceptions": [] }, + "themeColor": { + "state": "disabled", + "exceptions": [] + }, "navigatorInterface": { "state": "enabled", "exceptions": [] diff --git a/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json b/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json index 32831bd678..2cac7bdcef 100644 --- a/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json +++ b/injected/integration-test/test-pages/message-bridge/config/message-bridge-enabled.json @@ -9,6 +9,10 @@ "state": "disabled", "exceptions": [] }, + "themeColor": { + "state": "disabled", + "exceptions": [] + }, "messageBridge": { "exceptions": [], "state": "enabled", diff --git a/injected/integration-test/test-pages/theme-color/config/theme-color-absent.json b/injected/integration-test/test-pages/theme-color/config/theme-color-absent.json new file mode 100644 index 0000000000..fdd2c29b7e --- /dev/null +++ b/injected/integration-test/test-pages/theme-color/config/theme-color-absent.json @@ -0,0 +1,6 @@ +{ + "features": { + + }, + "unprotectedTemporary": [] +} diff --git a/injected/integration-test/test-pages/theme-color/config/theme-color-disabled.json b/injected/integration-test/test-pages/theme-color/config/theme-color-disabled.json new file mode 100644 index 0000000000..797bffdf1f --- /dev/null +++ b/injected/integration-test/test-pages/theme-color/config/theme-color-disabled.json @@ -0,0 +1,9 @@ +{ + "features": { + "themeColor": { + "state": "disabled", + "exceptions": [] + } + }, + "unprotectedTemporary": [] +} diff --git a/injected/integration-test/test-pages/theme-color/config/theme-color-enabled.json b/injected/integration-test/test-pages/theme-color/config/theme-color-enabled.json new file mode 100644 index 0000000000..5f153296ac --- /dev/null +++ b/injected/integration-test/test-pages/theme-color/config/theme-color-enabled.json @@ -0,0 +1,9 @@ +{ + "features": { + "themeColor": { + "state": "enabled", + "exceptions": [] + } + }, + "unprotectedTemporary": [] +} diff --git a/injected/integration-test/test-pages/theme-color/index.html b/injected/integration-test/test-pages/theme-color/index.html new file mode 100644 index 0000000000..73b387b874 --- /dev/null +++ b/injected/integration-test/test-pages/theme-color/index.html @@ -0,0 +1,11 @@ + + + + + + Theme Color Test + + + + + diff --git a/injected/integration-test/test-pages/theme-color/media-queries.html b/injected/integration-test/test-pages/theme-color/media-queries.html new file mode 100644 index 0000000000..8f552eca24 --- /dev/null +++ b/injected/integration-test/test-pages/theme-color/media-queries.html @@ -0,0 +1,13 @@ + + + + + + Theme Color Media Queries Test + + + + + + + diff --git a/injected/integration-test/test-pages/theme-color/no-theme-color.html b/injected/integration-test/test-pages/theme-color/no-theme-color.html new file mode 100644 index 0000000000..58b56391d8 --- /dev/null +++ b/injected/integration-test/test-pages/theme-color/no-theme-color.html @@ -0,0 +1,10 @@ + + + + + + No Theme Color Test + + + + diff --git a/injected/integration-test/theme-color.spec.js b/injected/integration-test/theme-color.spec.js new file mode 100644 index 0000000000..7ab32fb76b --- /dev/null +++ b/injected/integration-test/theme-color.spec.js @@ -0,0 +1,77 @@ +import { test, expect } from '@playwright/test'; +import { ResultsCollector } from './page-objects/results-collector.js'; + +const HTML = '/theme-color/index.html'; +const CONFIG = './integration-test/test-pages/theme-color/config/theme-color-enabled.json'; + +test('theme-color feature absent', async ({ page }, testInfo) => { + const CONFIG = './integration-test/test-pages/theme-color/config/theme-color-absent.json'; + const themeColor = ResultsCollector.create(page, testInfo.project.use); + await themeColor.load(HTML, CONFIG); + + const messages = await themeColor.waitForMessage('themeColorStatus', 1); + + expect(messages[0].payload.params).toStrictEqual({ + themeColor: '#ff0000', + documentUrl: 'http://localhost:3220/theme-color/index.html', + }); +}); + +test('theme-color (no theme color)', async ({ page }, testInfo) => { + const HTML = '/theme-color/no-theme-color.html'; + const themeColor = ResultsCollector.create(page, testInfo.project.use); + await themeColor.load(HTML, CONFIG); + + const messages = await themeColor.waitForMessage('themeColorStatus', 1); + + expect(messages[0].payload.params).toStrictEqual({ + themeColor: null, + documentUrl: 'http://localhost:3220/theme-color/no-theme-color.html', + }); +}); + +test('theme-color (viewport media query)', async ({ page }, testInfo) => { + // Use a desktop viewport + await page.setViewportSize({ width: 1280, height: 720 }); + + const HTML = '/theme-color/media-queries.html'; + const themeColor = ResultsCollector.create(page, testInfo.project.use); + await themeColor.load(HTML, CONFIG); + + const messages = await themeColor.waitForMessage('themeColorStatus', 1); + + expect(messages[0].payload.params).toStrictEqual({ + themeColor: '#00ff00', + documentUrl: 'http://localhost:3220/theme-color/media-queries.html', + }); +}); + +test('theme-color (color scheme media query)', async ({ page }, testInfo) => { + // Use a dark color scheme + await page.emulateMedia({ colorScheme: 'dark' }); + + const HTML = '/theme-color/media-queries.html'; + const themeColor = ResultsCollector.create(page, testInfo.project.use); + await themeColor.load(HTML, CONFIG); + + const messages = await themeColor.waitForMessage('themeColorStatus', 1); + + expect(messages[0].payload.params).toStrictEqual({ + themeColor: '#0000ff', + documentUrl: 'http://localhost:3220/theme-color/media-queries.html', + }); +}); + +test('theme-color feature disabled completely', async ({ page }, testInfo) => { + const CONFIG = './integration-test/test-pages/theme-color/config/theme-color-disabled.json'; + const themeColor = ResultsCollector.create(page, testInfo.project.use); + await themeColor.load(HTML, CONFIG); + + // this is here purely to guard against a false positive in this test. + // without this manual `wait`, it might be possible for the following assertion to + // pass, but just because it was too quick (eg: the first message wasn't sent yet) + await page.waitForTimeout(100); + + const messages = await themeColor.outgoingMessages(); + expect(messages).toHaveLength(0); +}); diff --git a/injected/playwright.config.js b/injected/playwright.config.js index 6a7b86caeb..5d6ca7f87c 100644 --- a/injected/playwright.config.js +++ b/injected/playwright.config.js @@ -37,7 +37,11 @@ export default defineConfig({ }, { name: 'ios', - testMatch: ['integration-test/duckplayer-mobile.spec.js', 'integration-test/duckplayer-mobile-drawer.spec.js'], + testMatch: [ + 'integration-test/duckplayer-mobile.spec.js', + 'integration-test/duckplayer-mobile-drawer.spec.js', + 'integration-test/theme-color.spec.js', + ], use: { injectName: 'apple-isolated', platform: 'ios', ...devices['iPhone 13'] }, }, { diff --git a/injected/src/features.js b/injected/src/features.js index be1345e430..e9a4534db4 100644 --- a/injected/src/features.js +++ b/injected/src/features.js @@ -27,13 +27,14 @@ const otherFeatures = /** @type {const} */ ([ 'breakageReporting', 'autofillPasswordImport', 'favicon', + 'themeColor', ]); /** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */ /** @type {Record} */ export const platformSupport = { apple: ['webCompat', ...baseFeatures], - 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon'], + 'apple-isolated': ['duckPlayer', 'brokerProtection', 'performanceMetrics', 'clickToLoad', 'messageBridge', 'favicon', 'themeColor'], android: [...baseFeatures, 'webCompat', 'breakageReporting', 'duckPlayer', 'messageBridge'], 'android-broker-protection': ['brokerProtection'], 'android-autofill-password-import': ['autofillPasswordImport'], diff --git a/injected/src/features/theme-color.js b/injected/src/features/theme-color.js new file mode 100644 index 0000000000..58e6f51c9c --- /dev/null +++ b/injected/src/features/theme-color.js @@ -0,0 +1,44 @@ +import ContentFeature from '../content-feature.js'; +import { isBeingFramed } from '../utils.js'; + +export class ThemeColor extends ContentFeature { + init() { + /** + * This feature never operates in a frame + */ + if (isBeingFramed()) return; + + window.addEventListener('DOMContentLoaded', () => { + // send once, immediately + this.send(); + }); + } + + send() { + const themeColor = getThemeColor(); + this.notify('themeColorStatus', { themeColor, documentUrl: document.URL }); + } +} + +export default ThemeColor; + +/** + * Gets current theme color considering media queries + * Follows browser behavior by returning the last matching meta tag in document order + * @returns {string|null} The theme color value or null if not found + */ +function getThemeColor() { + const metaTags = document.head.querySelectorAll('meta[name="theme-color"]'); + if (metaTags.length === 0) { + return null; + } + + let lastMatchingTag = null; + for (const meta of metaTags) { + const mediaAttr = meta.getAttribute('media'); + if (!mediaAttr || window.matchMedia(mediaAttr).matches) { + lastMatchingTag = meta; + } + } + return lastMatchingTag ? lastMatchingTag.getAttribute('content') : null; +} diff --git a/injected/src/messages/theme-color/themeColorStatus.notify.json b/injected/src/messages/theme-color/themeColorStatus.notify.json new file mode 100644 index 0000000000..6ca56bcf91 --- /dev/null +++ b/injected/src/messages/theme-color/themeColorStatus.notify.json @@ -0,0 +1,16 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "themeColorStatus", + "required": ["themeColor", "documentUrl"], + "properties": { + "themeColor": { + "type": ["string", "null"], + "description": "The theme color value, or null if not present" + }, + "documentUrl": { + "type": "string", + "description": "The URL of the document" + } + } +} diff --git a/injected/src/types/theme-color.ts b/injected/src/types/theme-color.ts new file mode 100644 index 0000000000..1a3de7378b --- /dev/null +++ b/injected/src/types/theme-color.ts @@ -0,0 +1,37 @@ +/** + * These types are auto-generated from schema files. + * scripts/build-types.mjs is responsible for type generation. + * **DO NOT** edit this file directly as your changes will be lost. + * + * @module ThemeColor Messages + */ + +/** + * Requests, Notifications and Subscriptions from the ThemeColor feature + */ +export interface ThemeColorMessages { + notifications: ThemeColorStatusNotification; +} +/** + * Generated from @see "../messages/theme-color/themeColorStatus.notify.json" + */ +export interface ThemeColorStatusNotification { + method: "themeColorStatus"; + params: ThemeColorStatus; +} +export interface ThemeColorStatus { + /** + * The theme color value, or null if not present + */ + themeColor: string | null; + /** + * The URL of the document + */ + documentUrl: string; +} + +declare module "../features/theme-color.js" { + export interface ThemeColor { + notify: import("@duckduckgo/messaging/lib/shared-types").MessagingBase['notify'] + } +} \ No newline at end of file diff --git a/injected/src/utils.js b/injected/src/utils.js index 2a5691b88d..3777d58c3a 100644 --- a/injected/src/utils.js +++ b/injected/src/utils.js @@ -703,7 +703,7 @@ export function isGloballyDisabled(args) { * @import {FeatureName} from "./features"; * @type {FeatureName[]} */ -export const platformSpecificFeatures = ['windowsPermissionUsage', 'messageBridge', 'favicon']; +export const platformSpecificFeatures = ['windowsPermissionUsage', 'messageBridge', 'favicon', 'themeColor']; export function isPlatformSpecificFeature(featureName) { return platformSpecificFeatures.includes(featureName);