Skip to content
Closed
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
30 changes: 30 additions & 0 deletions injected/docs/theme-color.md
Original file line number Diff line number Diff line change
@@ -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}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{
"features": {

"themeColor": {
"state": "disabled",
"exceptions": []
}
},
"unprotectedTemporary": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
"favicon": {
"state": "disabled",
"exceptions": []
},
"themeColor": {
Copy link
Contributor

Choose a reason for hiding this comment

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

I was going to ask why this needs to be added in all other tests but then I found this https://github.com/duckduckgo/content-scope-scripts/pull/1561/files#r2007736395 . Let's follow up on this with @shakyShane when he's back as this means new features such as this will have ever larger PRs.

"state": "disabled",
"exceptions": []
}
},
"unprotectedTemporary": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
"settings": {
"monitor": true
}
},
"themeColor": {
"state": "disabled",
"exceptions": []
}
},
"unprotectedTemporary": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
"settings": {
"monitor": false
}
},
"themeColor": {
"state": "disabled",
"exceptions": []
}
},
"unprotectedTemporary": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
"state": "disabled",
"exceptions": []
},
"themeColor": {
"state": "disabled",
"exceptions": []
},
"navigatorInterface": {
"state": "enabled",
"exceptions": []
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
"state": "disabled",
"exceptions": []
},
"themeColor": {
"state": "disabled",
"exceptions": []
},
"messageBridge": {
"exceptions": [],
"state": "enabled",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"features": {

},
"unprotectedTemporary": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"features": {
"themeColor": {
"state": "disabled",
"exceptions": []
}
},
"unprotectedTemporary": []
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"features": {
"themeColor": {
"state": "enabled",
"exceptions": []
}
},
"unprotectedTemporary": []
}
11 changes: 11 additions & 0 deletions injected/integration-test/test-pages/theme-color/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Theme Color Test</title>
<meta name="theme-color" content="#ff0000">
</head>
<body>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Theme Color Media Queries Test</title>
<meta name="theme-color" content="#ff0000">
<meta name="theme-color" content="#00ff00" media="(min-width: 600px)">
<meta name="theme-color" content="#0000ff" media="(prefers-color-scheme: dark)">
</head>
<body>
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>No Theme Color Test</title>
</head>
<body>
</body>
</html>
77 changes: 77 additions & 0 deletions injected/integration-test/theme-color.spec.js
Original file line number Diff line number Diff line change
@@ -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);
});
6 changes: 5 additions & 1 deletion injected/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
},
{
Expand Down
3 changes: 2 additions & 1 deletion injected/src/features.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ const otherFeatures = /** @type {const} */ ([
'breakageReporting',
'autofillPasswordImport',
'favicon',
'themeColor',
]);

/** @typedef {baseFeatures[number]|otherFeatures[number]} FeatureName */
/** @type {Record<string, FeatureName[]>} */
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'],
Expand Down
44 changes: 44 additions & 0 deletions injected/src/features/theme-color.js
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions injected/src/messages/theme-color/themeColorStatus.notify.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
37 changes: 37 additions & 0 deletions injected/src/types/theme-color.ts
Original file line number Diff line number Diff line change
@@ -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<ThemeColorMessages>['notify']
}
}
2 changes: 1 addition & 1 deletion injected/src/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading