Skip to content

Commit

Permalink
Add a set of CSS media query entropy sources
Browse files Browse the repository at this point in the history
Squashed commit of the following:

commit ee83891
Author: Surgie Finesse <finesserus@gmail.com>
Date:   Mon Jan 4 19:51:15 2021 +1000

    Add sources: HDR, reduced motion

commit 4a3b7b4
Author: Surgie Finesse <finesserus@gmail.com>
Date:   Mon Jan 4 13:15:34 2021 +1000

    Add sources: contrast, forced colors, monochrome

commit d8849ca
Author: Surgie Finesse <finesserus@gmail.com>
Date:   Wed Dec 30 18:07:38 2020 +1000

    Add the `invertedColors` source

commit e0e95f7
Author: Surgie Finesse <finesserus@gmail.com>
Date:   Wed Dec 30 17:53:33 2020 +1000

    Add the first CSS media source and tools to test CSS media sources
  • Loading branch information
Finesse committed Apr 1, 2021
1 parent 387eb83 commit d1c3ccf
Show file tree
Hide file tree
Showing 18 changed files with 448 additions and 37 deletions.
23 changes: 23 additions & 0 deletions src/sources/color_gamut.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { withMockMatchMedia } from '../../tests/utils'
import getColorGamut from './color_gamut'

describe('Sources', () => {
describe('colorGamut', () => {
it('handles browser native value', () => {
const colorGamut = getColorGamut()
expect([undefined, 'srgb', 'p3', 'rec2020']).toContain(colorGamut)
})

it('handles missing browser support', async () => {
await withMockMatchMedia({ 'color-gamut': [undefined] }, true, () => expect(getColorGamut()).toBeUndefined())
})

it('handles various color gamuts', async () => {
await withMockMatchMedia({ 'color-gamut': ['srgb'] }, true, () => expect(getColorGamut()).toBe('srgb'))
await withMockMatchMedia({ 'color-gamut': ['srgb', 'p3'] }, true, () => expect(getColorGamut()).toBe('p3'))
await withMockMatchMedia({ 'color-gamut': ['srgb', 'p3', 'rec2020'] }, true, () =>
expect(getColorGamut()).toBe('rec2020'),
)
})
})
})
14 changes: 14 additions & 0 deletions src/sources/color_gamut.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type ColorGamut = 'srgb' | 'p3' | 'rec2020'

/**
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/color-gamut
*/
export default function getColorGamut(): ColorGamut | undefined {
// rec2020 includes p3 and p3 includes srgb
for (const gamut of ['rec2020', 'p3', 'srgb'] as const) {
if (matchMedia(`(color-gamut: ${gamut})`).matches) {
return gamut
}
}
return undefined
}
40 changes: 40 additions & 0 deletions src/sources/contrast.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { withMockMatchMedia } from '../../tests/utils'
import getContrastPreference, { ContrastPreference } from './contrast'

describe('Sources', () => {
describe('contrast', () => {
it('handles browser native value', () => {
expect([
undefined,
ContrastPreference.Less,
ContrastPreference.None,
ContrastPreference.More,
ContrastPreference.ForcedColors,
]).toContain(getContrastPreference())
})

it('handles various cases', async () => {
await withMockMatchMedia({ 'prefers-contrast': [undefined] }, true, () =>
expect(getContrastPreference()).toBeUndefined(),
)
await withMockMatchMedia({ 'prefers-contrast': ['no-preference'] }, true, () =>
expect(getContrastPreference()).toBe(ContrastPreference.None),
)
await withMockMatchMedia({ 'prefers-contrast': ['high'] }, true, () =>
expect(getContrastPreference()).toBe(ContrastPreference.More),
)
await withMockMatchMedia({ 'prefers-contrast': ['more'] }, true, () =>
expect(getContrastPreference()).toBe(ContrastPreference.More),
)
await withMockMatchMedia({ 'prefers-contrast': ['low'] }, true, () =>
expect(getContrastPreference()).toBe(ContrastPreference.Less),
)
await withMockMatchMedia({ 'prefers-contrast': ['less'] }, true, () =>
expect(getContrastPreference()).toBe(ContrastPreference.Less),
)
await withMockMatchMedia({ 'prefers-contrast': ['forced'] }, true, () =>
expect(getContrastPreference()).toBe(ContrastPreference.ForcedColors),
)
})
})
})
33 changes: 33 additions & 0 deletions src/sources/contrast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
export const enum ContrastPreference {
Less = -1,
None = 0,
More = 1,
// "Max" can be added in future
ForcedColors = 10,
}

/**
* @see https://www.w3.org/TR/mediaqueries-5/#prefers-contrast
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-contrast
*/
export default function getContrastPreference(): ContrastPreference | undefined {
if (doesMatch('no-preference')) {
return ContrastPreference.None
}
// The sources contradict on the keywords. Probably 'high' and 'low' will never be implemented.
// Need to check it when all browsers implement the feature.
if (doesMatch('high') || doesMatch('more')) {
return ContrastPreference.More
}
if (doesMatch('low') || doesMatch('less')) {
return ContrastPreference.Less
}
if (doesMatch('forced')) {
return ContrastPreference.ForcedColors
}
return undefined
}

function doesMatch(value: string) {
return matchMedia(`(prefers-contrast: ${value})`).matches
}
16 changes: 16 additions & 0 deletions src/sources/forced_colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { withMockMatchMedia } from '../../tests/utils'
import areColorsForced from './forced_colors'

describe('Sources', () => {
describe('forcedColors', () => {
it('handles browser native value', () => {
expect([undefined, true, false]).toContain(areColorsForced())
})

it('handles various cases', async () => {
await withMockMatchMedia({ 'forced-colors': [undefined] }, true, () => expect(areColorsForced()).toBeUndefined())
await withMockMatchMedia({ 'forced-colors': ['none'] }, true, () => expect(areColorsForced()).toBeFalse())
await withMockMatchMedia({ 'forced-colors': ['active'] }, true, () => expect(areColorsForced()).toBeTrue())
})
})
})
16 changes: 16 additions & 0 deletions src/sources/forced_colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/forced-colors
*/
export default function areColorsForced(): boolean | undefined {
if (doesMatch('active')) {
return true
}
if (doesMatch('none')) {
return false
}
return undefined
}

function doesMatch(value: string) {
return matchMedia(`(forced-colors: ${value})`).matches
}
16 changes: 16 additions & 0 deletions src/sources/hdr.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { withMockMatchMedia } from '../../tests/utils'
import isHDR from './hdr'

describe('Sources', () => {
describe('hdr', () => {
it('handles browser native value', () => {
expect([undefined, true, false]).toContain(isHDR())
})

it('handles various cases', async () => {
await withMockMatchMedia({ 'dynamic-range': [undefined] }, true, () => expect(isHDR()).toBeUndefined())
await withMockMatchMedia({ 'dynamic-range': ['high'] }, true, () => expect(isHDR()).toBeTrue())
await withMockMatchMedia({ 'dynamic-range': ['standard'] }, true, () => expect(isHDR()).toBeFalse())
})
})
})
16 changes: 16 additions & 0 deletions src/sources/hdr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @see https://www.w3.org/TR/mediaqueries-5/#dynamic-range
*/
export default function isHDR(): boolean | undefined {
if (doesMatch('high')) {
return true
}
if (doesMatch('standard')) {
return false
}
return undefined
}

function doesMatch(value: string) {
return matchMedia(`(dynamic-range: ${value})`).matches
}
14 changes: 14 additions & 0 deletions src/sources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ import getVendor from './vendor'
import getVendorFlavors from './vendor_flavors'
import areCookiesEnabled from './cookies_enabled'
import getDomBlockers from './dom_blockers'
import getColorGamut from './color_gamut'
import areColorsInverted from './inverted_colors'
import areColorsForced from './forced_colors'
import getMonochromeDepth from './monochrome'
import getContrastPreference from './contrast'
import isMotionReduced from './reduced_motion'
import isHDR from './hdr'

/**
* The list of entropy sources used to make visitor identifiers.
Expand Down Expand Up @@ -58,6 +65,13 @@ export const sources = {
vendor: getVendor,
vendorFlavors: getVendorFlavors,
cookiesEnabled: areCookiesEnabled,
colorGamut: getColorGamut,
invertedColors: areColorsInverted,
forcedColors: areColorsForced,
monochrome: getMonochromeDepth,
contrast: getContrastPreference,
reducedMotion: isMotionReduced,
hdr: isHDR,
}

/**
Expand Down
18 changes: 18 additions & 0 deletions src/sources/inverted_colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { withMockMatchMedia } from '../../tests/utils'
import areColorsInverted from './inverted_colors'

describe('Sources', () => {
describe('invertedColors', () => {
it('handles browser native value', () => {
expect([undefined, true, false]).toContain(areColorsInverted())
})

it('handles various cases', async () => {
await withMockMatchMedia({ 'inverted-colors': [undefined] }, true, () =>
expect(areColorsInverted()).toBeUndefined(),
)
await withMockMatchMedia({ 'inverted-colors': ['none'] }, true, () => expect(areColorsInverted()).toBeFalse())
await withMockMatchMedia({ 'inverted-colors': ['inverted'] }, true, () => expect(areColorsInverted()).toBeTrue())
})
})
})
16 changes: 16 additions & 0 deletions src/sources/inverted_colors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/inverted-colors
*/
export default function areColorsInverted(): boolean | undefined {
if (doesMatch('inverted')) {
return true
}
if (doesMatch('none')) {
return false
}
return undefined
}

function doesMatch(value: string) {
return matchMedia(`(inverted-colors: ${value})`).matches
}
17 changes: 17 additions & 0 deletions src/sources/monochrome.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { withMockMatchMedia } from '../../tests/utils'
import getMonochromeDepth from './monochrome'

describe('Sources', () => {
describe('monochrome', () => {
it('handles browser native value', () => {
expect(['undefined', 'number']).toContain(typeof getMonochromeDepth())
})

it('handles various cases', async () => {
await withMockMatchMedia({ monochrome: [undefined] }, true, () => expect(getMonochromeDepth()).toBeUndefined())
await withMockMatchMedia({ monochrome: [0] }, true, () => expect(getMonochromeDepth()).toBe(0))
await withMockMatchMedia({ monochrome: [8] }, true, () => expect(getMonochromeDepth()).toBe(8))
await withMockMatchMedia({ monochrome: [10] }, true, () => expect(getMonochromeDepth()).toBe(10))
})
})
})
25 changes: 25 additions & 0 deletions src/sources/monochrome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const maxValueToCheck = 100

/**
* If the display is monochrome (e.g. black&white), the value will be ≥0 and will mean the number of bits per pixel.
* If the display is not monochrome, the returned value will be 0.
* If the browser doesn't support this feature, the returned value will be undefined.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/monochrome
*/
export default function getMonochromeDepth(): number | undefined {
if (!matchMedia('(min-monochrome: 0)').matches) {
// The media feature isn't supported by the browser
return undefined
}

// A variation of binary search algorithm can be used here.
// But since expected values are very small (≤10), there is no sense in adding the complexity.
for (let i = 0; i <= maxValueToCheck; ++i) {
if (matchMedia(`(max-monochrome: ${i})`).matches) {
return i
}
}

throw new Error('Too high value')
}
22 changes: 22 additions & 0 deletions src/sources/reduced_motion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { withMockMatchMedia } from '../../tests/utils'
import isMotionReduced from './reduced_motion'

describe('Sources', () => {
describe('reducedMotion', () => {
it('handles browser native value', () => {
expect([undefined, true, false]).toContain(isMotionReduced())
})

it('handles various cases', async () => {
await withMockMatchMedia({ 'prefers-reduced-motion': [undefined] }, true, () =>
expect(isMotionReduced()).toBeUndefined(),
)
await withMockMatchMedia({ 'prefers-reduced-motion': ['no-preference'] }, true, () =>
expect(isMotionReduced()).toBeFalse(),
)
await withMockMatchMedia({ 'prefers-reduced-motion': ['reduce'] }, true, () =>
expect(isMotionReduced()).toBeTrue(),
)
})
})
})
16 changes: 16 additions & 0 deletions src/sources/reduced_motion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion
*/
export default function isMotionReduced(): boolean | undefined {
if (doesMatch('reduce')) {
return true
}
if (doesMatch('no-preference')) {
return false
}
return undefined
}

function doesMatch(value: string) {
return matchMedia(`(prefers-reduced-motion: ${value})`).matches
}
40 changes: 3 additions & 37 deletions tests/utils.ts → tests/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { UAParser } from 'ua-parser-js'

export { default as withMockProperties } from './mock_properties'
export { default as withMockMatchMedia } from './mock_match_media'

/*
* Sometimes tests need to know what browser they run in to make proper assertions.
* Karma doesn't provide this information.
Expand Down Expand Up @@ -69,40 +72,3 @@ export function getBrowserEngineMajorVersion(): number | undefined {
}
return parseInt(version.split('.')[0])
}

/**
* Sets new property values to the object and reverts the properties when the action is complete
*/
export async function withMockProperties<T>(
object: Record<never, unknown>,
mockProperties: Record<string, PropertyDescriptor | undefined>,
action: () => Promise<T> | T,
): Promise<T> {
const originalProperties: Record<string, PropertyDescriptor | undefined> = {}

for (const property of Object.keys(mockProperties)) {
originalProperties[property] = Object.getOwnPropertyDescriptor(object, property)
const mockProperty = mockProperties[property]
if (mockProperty) {
Object.defineProperty(object, property, {
...mockProperty,
configurable: true, // Must be configurable, otherwise won't be able to revert
})
} else {
delete (object as Record<keyof never, unknown>)[property]
}
}

try {
return await action()
} finally {
for (const property of Object.keys(originalProperties)) {
const propertyDescriptor = originalProperties[property]
if (propertyDescriptor === undefined) {
delete (object as Record<keyof never, unknown>)[property]
} else {
Object.defineProperty(object, property, propertyDescriptor)
}
}
}
}

0 comments on commit d1c3ccf

Please sign in to comment.