Make engine stream react to prefers-color-scheme
media query change (#4008)
* Add event listener for theme media query to update engine theme * tsc fixes * Add a Playwright test for UI and engine theme switch * A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu-latest) --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
@ -48,6 +48,11 @@ export const TEST_SETTINGS_ONBOARDING_START = {
|
|||||||
app: { ...TEST_SETTINGS.app, onboardingStatus: '' },
|
app: { ...TEST_SETTINGS.app, onboardingStatus: '' },
|
||||||
} satisfies Partial<SaveSettingsPayload>
|
} satisfies Partial<SaveSettingsPayload>
|
||||||
|
|
||||||
|
export const TEST_SETTINGS_DEFAULT_THEME = {
|
||||||
|
...TEST_SETTINGS,
|
||||||
|
app: { ...TEST_SETTINGS.app, theme: Themes.System },
|
||||||
|
} satisfies Partial<SaveSettingsPayload>
|
||||||
|
|
||||||
export const TEST_SETTINGS_CORRUPTED = {
|
export const TEST_SETTINGS_CORRUPTED = {
|
||||||
app: {
|
app: {
|
||||||
theme: Themes.Dark,
|
theme: Themes.Dark,
|
||||||
|
@ -13,6 +13,7 @@ import {
|
|||||||
TEST_SETTINGS_KEY,
|
TEST_SETTINGS_KEY,
|
||||||
TEST_SETTINGS_CORRUPTED,
|
TEST_SETTINGS_CORRUPTED,
|
||||||
TEST_SETTINGS,
|
TEST_SETTINGS,
|
||||||
|
TEST_SETTINGS_DEFAULT_THEME,
|
||||||
} from './storageStates'
|
} from './storageStates'
|
||||||
import * as TOML from '@iarna/toml'
|
import * as TOML from '@iarna/toml'
|
||||||
|
|
||||||
@ -656,6 +657,60 @@ const extrude001 = extrude(5, sketch001)
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test(`Changing system theme preferences (via media query) should update UI and stream`, async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
// Override the settings so that the theme is set to `system`
|
||||||
|
await page.addInitScript(
|
||||||
|
({ settingsKey, settings }) => {
|
||||||
|
localStorage.setItem(settingsKey, settings)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
settingsKey: TEST_SETTINGS_KEY,
|
||||||
|
settings: TOML.stringify({
|
||||||
|
settings: TEST_SETTINGS_DEFAULT_THEME,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const u = await getUtils(page)
|
||||||
|
|
||||||
|
// Selectors and constants
|
||||||
|
const darkBackgroundCss = 'oklch(0.3012 0 264.5)'
|
||||||
|
const lightBackgroundCss = 'oklch(0.9911 0 264.5)'
|
||||||
|
const darkBackgroundColor: [number, number, number] = [27, 27, 27]
|
||||||
|
const lightBackgroundColor: [number, number, number] = [245, 245, 245]
|
||||||
|
const streamBackgroundPixelIsColor = async (
|
||||||
|
color: [number, number, number]
|
||||||
|
) => {
|
||||||
|
return u.getGreatestPixDiff({ x: 1000, y: 200 }, color)
|
||||||
|
}
|
||||||
|
const toolbar = page.locator('menu').filter({ hasText: 'Start Sketch' })
|
||||||
|
|
||||||
|
await test.step(`Test setup`, async () => {
|
||||||
|
await page.setViewportSize({ width: 1200, height: 500 })
|
||||||
|
await u.waitForAuthSkipAppStart()
|
||||||
|
await expect(toolbar).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step(`Check the background color is light before`, async () => {
|
||||||
|
await expect(toolbar).toHaveCSS('background-color', lightBackgroundCss)
|
||||||
|
await expect
|
||||||
|
.poll(() => streamBackgroundPixelIsColor(lightBackgroundColor))
|
||||||
|
.toBeLessThan(15)
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step(`Change media query preference to dark, emulating dusk with system theme`, async () => {
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' })
|
||||||
|
})
|
||||||
|
|
||||||
|
await test.step(`Check the background color is dark after`, async () => {
|
||||||
|
await expect(toolbar).toHaveCSS('background-color', darkBackgroundCss)
|
||||||
|
await expect
|
||||||
|
.poll(() => streamBackgroundPixelIsColor(darkBackgroundColor))
|
||||||
|
.toBeLessThan(15)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
test(`Turning off "Show debug panel" with debug panel open leaves no phantom panel`, async ({
|
test(`Turning off "Show debug panel" with debug panel open leaves no phantom panel`, async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
@ -8,7 +8,7 @@ import useStateMachineCommands from '../hooks/useStateMachineCommands'
|
|||||||
import { settingsMachine } from 'machines/settingsMachine'
|
import { settingsMachine } from 'machines/settingsMachine'
|
||||||
import { toast } from 'react-hot-toast'
|
import { toast } from 'react-hot-toast'
|
||||||
import {
|
import {
|
||||||
getThemeColorForEngine,
|
darkModeMatcher,
|
||||||
getOppositeTheme,
|
getOppositeTheme,
|
||||||
setThemeClass,
|
setThemeClass,
|
||||||
Themes,
|
Themes,
|
||||||
@ -34,6 +34,7 @@ import { useCommandsContext } from 'hooks/useCommandsContext'
|
|||||||
import { Command } from 'lib/commandTypes'
|
import { Command } from 'lib/commandTypes'
|
||||||
import { BaseUnit } from 'lib/settings/settingsTypes'
|
import { BaseUnit } from 'lib/settings/settingsTypes'
|
||||||
import { saveSettings } from 'lib/settings/settingsUtils'
|
import { saveSettings } from 'lib/settings/settingsUtils'
|
||||||
|
import { reportRejection } from 'lib/trap'
|
||||||
|
|
||||||
type MachineContext<T extends AnyStateMachine> = {
|
type MachineContext<T extends AnyStateMachine> = {
|
||||||
state: StateFrom<T>
|
state: StateFrom<T>
|
||||||
@ -113,26 +114,9 @@ export const SettingsAuthProviderBase = ({
|
|||||||
sceneInfra.baseUnit = newBaseUnit
|
sceneInfra.baseUnit = newBaseUnit
|
||||||
},
|
},
|
||||||
setEngineTheme: ({ context }) => {
|
setEngineTheme: ({ context }) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
engineCommandManager
|
||||||
engineCommandManager.sendSceneCommand({
|
.setTheme(context.app.theme.current)
|
||||||
cmd_id: uuidv4(),
|
.catch(reportRejection)
|
||||||
type: 'modeling_cmd_req',
|
|
||||||
cmd: {
|
|
||||||
type: 'set_background_color',
|
|
||||||
color: getThemeColorForEngine(context.app.theme.current),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const opposingTheme = getOppositeTheme(context.app.theme.current)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
engineCommandManager.sendSceneCommand({
|
|
||||||
cmd_id: uuidv4(),
|
|
||||||
type: 'modeling_cmd_req',
|
|
||||||
cmd: {
|
|
||||||
type: 'set_default_system_properties',
|
|
||||||
color: getThemeColorForEngine(opposingTheme),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
setEngineScaleGridVisibility: ({ context }) => {
|
setEngineScaleGridVisibility: ({ context }) => {
|
||||||
engineCommandManager.setScaleGridVisibility(
|
engineCommandManager.setScaleGridVisibility(
|
||||||
@ -261,14 +245,13 @@ export const SettingsAuthProviderBase = ({
|
|||||||
// because there doesn't seem to be a good way to listen to
|
// because there doesn't seem to be a good way to listen to
|
||||||
// events outside of the machine that also depend on the machine's context
|
// events outside of the machine that also depend on the machine's context
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const matcher = window.matchMedia('(prefers-color-scheme: dark)')
|
|
||||||
const listener = (e: MediaQueryListEvent) => {
|
const listener = (e: MediaQueryListEvent) => {
|
||||||
if (settingsState.context.app.theme.current !== 'system') return
|
if (settingsState.context.app.theme.current !== 'system') return
|
||||||
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
|
setThemeClass(e.matches ? Themes.Dark : Themes.Light)
|
||||||
}
|
}
|
||||||
|
|
||||||
matcher.addEventListener('change', listener)
|
darkModeMatcher?.addEventListener('change', listener)
|
||||||
return () => matcher.removeEventListener('change', listener)
|
return () => darkModeMatcher?.removeEventListener('change', listener)
|
||||||
}, [settingsState.context])
|
}, [settingsState.context])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -3,7 +3,12 @@ import { VITE_KC_API_WS_MODELING_URL, VITE_KC_DEV_TOKEN } from 'env'
|
|||||||
import { Models } from '@kittycad/lib'
|
import { Models } from '@kittycad/lib'
|
||||||
import { exportSave } from 'lib/exportSave'
|
import { exportSave } from 'lib/exportSave'
|
||||||
import { deferExecution, isOverlap, uuidv4 } from 'lib/utils'
|
import { deferExecution, isOverlap, uuidv4 } from 'lib/utils'
|
||||||
import { Themes, getThemeColorForEngine, getOppositeTheme } from 'lib/theme'
|
import {
|
||||||
|
Themes,
|
||||||
|
getThemeColorForEngine,
|
||||||
|
getOppositeTheme,
|
||||||
|
darkModeMatcher,
|
||||||
|
} from 'lib/theme'
|
||||||
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
|
import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
|
||||||
import {
|
import {
|
||||||
ArtifactGraph,
|
ArtifactGraph,
|
||||||
@ -1393,6 +1398,9 @@ export class EngineCommandManager extends EventTarget {
|
|||||||
|
|
||||||
private onEngineConnectionOpened = () => {}
|
private onEngineConnectionOpened = () => {}
|
||||||
private onEngineConnectionClosed = () => {}
|
private onEngineConnectionClosed = () => {}
|
||||||
|
private onDarkThemeMediaQueryChange = (e: MediaQueryListEvent) => {
|
||||||
|
this.setTheme(e.matches ? Themes.Dark : Themes.Light).catch(reportRejection)
|
||||||
|
}
|
||||||
private onEngineConnectionStarted = ({ detail: engineConnection }: any) => {}
|
private onEngineConnectionStarted = ({ detail: engineConnection }: any) => {}
|
||||||
private onEngineConnectionNewTrack = ({
|
private onEngineConnectionNewTrack = ({
|
||||||
detail,
|
detail,
|
||||||
@ -1479,30 +1487,13 @@ export class EngineCommandManager extends EventTarget {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||||
this.onEngineConnectionOpened = async () => {
|
this.onEngineConnectionOpened = async () => {
|
||||||
// Set the stream background color
|
// Set the theme
|
||||||
// This takes RGBA values from 0-1
|
this.setTheme(this.settings.theme).catch(reportRejection)
|
||||||
// So we convert from the conventional 0-255 found in Figma
|
// Set up a listener for the dark theme media query
|
||||||
|
darkModeMatcher?.addEventListener(
|
||||||
void this.sendSceneCommand({
|
'change',
|
||||||
type: 'modeling_cmd_req',
|
this.onDarkThemeMediaQueryChange
|
||||||
cmd_id: uuidv4(),
|
)
|
||||||
cmd: {
|
|
||||||
type: 'set_background_color',
|
|
||||||
color: getThemeColorForEngine(this.settings.theme),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sets the default line colors
|
|
||||||
const opposingTheme = getOppositeTheme(this.settings.theme)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
||||||
this.sendSceneCommand({
|
|
||||||
cmd_id: uuidv4(),
|
|
||||||
type: 'modeling_cmd_req',
|
|
||||||
cmd: {
|
|
||||||
type: 'set_default_system_properties',
|
|
||||||
color: getThemeColorForEngine(opposingTheme),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Set the edge lines visibility
|
// Set the edge lines visibility
|
||||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||||
@ -1793,6 +1784,10 @@ export class EngineCommandManager extends EventTarget {
|
|||||||
EngineConnectionEvents.NewTrack,
|
EngineConnectionEvents.NewTrack,
|
||||||
this.onEngineConnectionNewTrack as EventListener
|
this.onEngineConnectionNewTrack as EventListener
|
||||||
)
|
)
|
||||||
|
darkModeMatcher?.removeEventListener(
|
||||||
|
'change',
|
||||||
|
this.onDarkThemeMediaQueryChange
|
||||||
|
)
|
||||||
|
|
||||||
this.engineConnection?.tearDown(opts)
|
this.engineConnection?.tearDown(opts)
|
||||||
|
|
||||||
@ -2155,6 +2150,34 @@ export class EngineCommandManager extends EventTarget {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the engine's theme
|
||||||
|
*/
|
||||||
|
async setTheme(theme: Themes) {
|
||||||
|
// Set the stream background color
|
||||||
|
// This takes RGBA values from 0-1
|
||||||
|
// So we convert from the conventional 0-255 found in Figma
|
||||||
|
this.sendSceneCommand({
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd: {
|
||||||
|
type: 'set_background_color',
|
||||||
|
color: getThemeColorForEngine(theme),
|
||||||
|
},
|
||||||
|
}).catch(reportRejection)
|
||||||
|
|
||||||
|
// Sets the default line colors
|
||||||
|
const opposingTheme = getOppositeTheme(theme)
|
||||||
|
this.sendSceneCommand({
|
||||||
|
cmd_id: uuidv4(),
|
||||||
|
type: 'modeling_cmd_req',
|
||||||
|
cmd: {
|
||||||
|
type: 'set_default_system_properties',
|
||||||
|
color: getThemeColorForEngine(opposingTheme),
|
||||||
|
},
|
||||||
|
}).catch(reportRejection)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the visibility of the scale grid in the engine scene.
|
* Set the visibility of the scale grid in the engine scene.
|
||||||
* @param visible - whether to show or hide the scale grid
|
* @param visible - whether to show or hide the scale grid
|
||||||
|
@ -1,5 +1,12 @@
|
|||||||
import { AppTheme } from 'wasm-lib/kcl/bindings/AppTheme'
|
import { AppTheme } from 'wasm-lib/kcl/bindings/AppTheme'
|
||||||
|
|
||||||
|
/** A media query matcher for dark mode */
|
||||||
|
export const darkModeMatcher =
|
||||||
|
(typeof globalThis.window !== 'undefined' &&
|
||||||
|
'matchMedia' in globalThis.window &&
|
||||||
|
globalThis.window.matchMedia('(prefers-color-scheme: dark)')) ||
|
||||||
|
undefined
|
||||||
|
|
||||||
export enum Themes {
|
export enum Themes {
|
||||||
Light = 'light',
|
Light = 'light',
|
||||||
Dark = 'dark',
|
Dark = 'dark',
|
||||||
@ -25,7 +32,7 @@ export function appThemeToTheme(
|
|||||||
export function getSystemTheme(): Exclude<Themes, 'system'> {
|
export function getSystemTheme(): Exclude<Themes, 'system'> {
|
||||||
return typeof globalThis.window !== 'undefined' &&
|
return typeof globalThis.window !== 'undefined' &&
|
||||||
'matchMedia' in globalThis.window
|
'matchMedia' in globalThis.window
|
||||||
? window.matchMedia('(prefers-color-scheme: dark)').matches
|
? darkModeMatcher?.matches
|
||||||
? Themes.Dark
|
? Themes.Dark
|
||||||
: Themes.Light
|
: Themes.Light
|
||||||
: Themes.Light
|
: Themes.Light
|
||||||
|
Reference in New Issue
Block a user